docs(dispatch): 0-Z/0-A 확정 — Attribute 전용 키+이름 claim, 하강 diff 전면 반영

`question.md` 최우선 두 건을 한 패스로 닫고, 배너를 달고 있던 7개 문서 +
인덱스 레이어 전체를 갱신했다.

0-Z (Attribute 이름 소유권):
- 그룹은 비공개 `GetKey`로 이름마다 자기 전용 키를 써서 위임 → 교차 오염
  구조적 제거. 이름 소유권은 `AttributeKeyHandler`의 이름 claim이 판정
  (`nameClaims` Relate, 다른 키가 같은 이름을 노리면 즉시 error).
- 권고안 (a)(그룹 안 claimant Relate)는 그룹↔직접 쓰기를 못 잡아 기각 —
  두 경로가 만나는 말단 핸들러에서 공개 키는 같은 객체라 소유자 구분 불가.

0-A (재디스패치 = 하강 diff):
- 래핑 핸들러의 선행 `retractFrom` 폐기, `Dispatch.process`가 슬롯의
  `handler`를 먼저 비교(같으면 클로저에 새 값 전달 후 재process, 다르면
  그 자리부터 전량 철거).
- 귀결: `retractFrom`이 3-인자로 축소(힌트를 외부에서 만들어 넣을 자리
  소멸), `isX(hintValue)` 가드 규칙 폐지, 깊은 체인 힌트 유실 캐비엇 삭제,
  Dispatch의 점유 체크 폐지.
- 9차 세션이 미뤄둔 2단계 분할을 같이 수행 — 디스패치 코어를
  `base/dispatch-core-plan.md`로 분리하며 재작성(bind-system-plan은
  2263→1219줄). 옛 모델은 archive/dispatch-hintvalue-model-reversed.md.

패키지 재배치 (사용자 제기):
- Tag/Attribute의 부기 알고리즘 전체를 quad-base로, 백엔드는
  `addTag`/`removeTag(inst,{string})`/`setAttribute(inst,name,v)` 3개 op만
  주입(웹 className/data-* 대응). 엔진 고유 타입 패밀리만 백엔드.
- `HANDLER_PRIORITY_FALLBACK` 신설 — base 제공 핸들러의 밴드, 백엔드가
  평범한 우선순위로 덮어쓰면 언제나 이김.

부수: 스파이크 04/19가 옛 모델을 검증 중이라 rewrite-required로 이동,
question.md 최우선 칸 비움, 새 소소 항목 2건 등록(Merged 이름 중복,
`hintValue` 이름 재검토). doc-check ERROR 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KckSawrsJSJmDBcSojJPxZ
This commit is contained in:
qwreey 2026-08-13 23:53:15 +09:00
parent 1f4f21c75a
commit 69466abc47
Signed by: qwreey
GPG key ID: D28DB79297A214BD
28 changed files with 2252 additions and 1784 deletions

File diff suppressed because one or more lines are too long

View file

@ -1,16 +1,42 @@
# 재디스패치 = 하강 diff — `retractFrom` 선행 폐기, 핸들러 비교를 클로저 호출 앞에 (설계안) # [역전됨] 재디스패치 = "철거 후 재구축" + `hintValue` 힌트 모델
**상태**: research — 2026-08-13 여섯 번째 세션에 사용자가 제기하고 방향을 **상태**: 역전됨 — 2026-08-13 여섯 번째 세션에 사용자가 결함을 제기하고
제시, 같은 세션 후속 라운드에서 모델이 거의 확정됨. **`base/` 반영 전 방향을 제시, 같은 날 열네 번째 세션에 마지막 열린 항목(Attribute 이름
남은 열린 항목은 아래 5절의 하나뿐**(Attribute 이름 소유권). 그 하나만 소유권)까지 확정되며 **`base/dispatch-core-plan.md`로 전면 반영 완료**.
정해지면 `bind-system-plan.md`/`tag-plan.md`/`slot-plan.md`/ 이 문서는 그때까지 research/ 아래 dispatch-redispatch-diff-plan이었던 설계안
`attribute-plan.md`를 한 번에 옮기면 됨. 원문이고, **지금 유효한 모델은 `base/dispatch-core-plan.md`의 "Dispatch
체인" 절** — 여기 남기는 이유는 "왜 힌트 모델을 버렸는가"의 재현 사례와
근거가 통째로 보존될 가치가 있어서(같은 함정을 다시 설계하지 않도록).
> **문서 이력**: 최초엔 dispatch-hint-to-oldvalue-plan(현재 없는 옛 파일명)으로 ## 뒤집힌 옛 모델의 골자 (원문 보존)
> "힌트 대신 `oldValue`를 넘기자"는 보완안을 담고 있었으나, 사용자가
> **"이전 값인 oldValue는 처음부터 클로저라 이미 본인이 알지 않아요?"** ```lua
> 라고 지적해 그 보완안이 통째로 불필요함이 드러남(아래 3-2) — 파일명도 -- 옛 계약: 래핑 핸들러가 재-dispatch 전에 자기 아래를 먼저 철거하고,
> 바꿈. -- 그때 "곧 디스패치될 raw 값"을 힌트로 실어 보냄.
function StoreBind.process(inst, k, state, index)
local observer = state:Observer(function()
local realv = state:Get()
Dispatch.retractFrom(inst, k, index + 1, realv) -- 선행 철거 + 힌트
Dispatch.process(inst, k, realv, index + 1)
end)
...
end
function Dispatch.retractFrom(inst, k, index, v) -- 4-인자(마지막이 힌트)
...
retractor(if i == index then v else nil) -- 힌트는 직속 1단계에만
...
end
```
옛 모델이 같이 요구하던 규칙들(전부 지금은 폐기):
- **`isX(hintValue)` 가드 필수** — 힌트의 타입이 계약으로 보장되지 않아서.
- **`hintValue`는 직속 위임 1단계에만 보장, 깊은 인덱스는 항상 `nil`** —
그래서 `State<State<Tag>>`류에서 깜빡임 방지가 조용히 꺼짐.
- **`Dispatch.process`의 점유 체크(이미 점유된 인덱스면 즉시 error)** —
Attribute 이름 소유권이 이 부수 효과에 얹혀 있었음.
## 아래는 역전을 이끈 분석 원문 (당시 `research/` 문서 그대로)
## 1. 현행 `hintValue`의 실제 결함 (사용자 지적, 확인됨) ## 1. 현행 `hintValue`의 실제 결함 (사용자 지적, 확인됨)
@ -267,3 +293,21 @@ Dispatch.process(inst, k, realv, index + 1) -- retractFrom 선행 호출 없
**요약**: 배너를 달고 있는 파일 = 반영 대상. 위 7개(`bind-system-plan`/ **요약**: 배너를 달고 있는 파일 = 반영 대상. 위 7개(`bind-system-plan`/
`tag-plan`/`slot-plan`/`attribute-plan`/`architecture`/`ROADMAP`/ `tag-plan`/`slot-plan`/`attribute-plan`/`architecture`/`ROADMAP`/
`ref-plan`)가 전부이고, 반영이 끝나면 각 파일의 ⚠️ 배너도 같이 제거할 것. `ref-plan`)가 전부이고, 반영이 끝나면 각 파일의 ⚠️ 배너도 같이 제거할 것.
---
## 최종 결말 (2026-08-13 열네 번째 세션)
- **5절의 열린 항목은 (a)가 아니라 그 변형으로 확정됨** — 사용자가 제시한
**그룹 전용 키(`GetKey`, 비공개) + `AttributeKeyHandler`의 이름 claim**.
(a)(그룹 안에 이름별 claimant `Relate`)를 그대로 쓰면 **그룹↔직접 쓰기
충돌을 못 잡는다**는 게 트레이싱으로 드러났기 때문 — 두 경로가 만나는
유일한 지점인 말단 핸들러에서 `k`가 같은 객체라 소유자를 구분할 수 없음.
근거와 최종 의사코드는 `base/attribute-plan.md` "이름 소유권" 절.
- **`Dispatch.retractFrom`은 4-인자에서 3-인자가 됐음** — 값을 넘기는
경로가 `Dispatch.process`의 "같은 핸들러" 분기 하나로 통일되면서, 외부가
힌트를 만들어 넣을 자리 자체가 없어짐(옛 결함이 구조적으로 재발 불가).
- 6절의 반영 목록(7개 문서 + `bind-system-plan.md` 2단계 분할)은 같은
세션에 전부 수행됨 — 디스패치 코어는 `base/dispatch-core-plan.md`
분리되며 재작성됐다.

View file

@ -104,7 +104,17 @@ API 전부에 걸리며, 2026-08-07 일곱 번째 세션(커링 스타일 확정
정상 nilable 사용례까지 막음), `16`(`type function` API 불일치). 전부 정상 nilable 사용례까지 막음), `16`(`type function` API 불일치). 전부
`audit/luau-test-first-run-2026-08-13.md`. `audit/luau-test-first-run-2026-08-13.md`.
### 0-Z. ⭐ **최우선 — Attribute 이름 소유권을 무엇으로 판정할 것인가** (2026-08-13 여섯 번째 세션, 사용자가 다음 세션 심층 분석으로 이관) ### 0-Z. ~~⭐ 최우선~~ **[해소됨, 2026-08-13 열네 번째 세션]** — Attribute 이름 소유권을 무엇으로 판정할 것인가 (2026-08-13 여섯 번째 세션 신설)
> **결론**: 후보 (a)가 아니라 그 변형 — **그룹 전용 키(비공개 `GetKey`)
> + `AttributeKeyHandler`의 이름 claim**. 사용자가 `Attribute:GetKey(name)`
> 아이디어를 제시했고, 트레이싱 결과 (a)만으로는 **그룹↔직접 쓰기 충돌을
> 못 잡는다**는 게 드러나(두 경로가 만나는 말단 핸들러에서 공개 키는
> 같은 객체라 소유자 구분 불가) 전용 키가 필요함이 확인됨. 전용 키가
> 교차 오염을 구조적으로 없애고, claim이 남은 충돌을 즉시 error로
> 드러냄. `GetKey`는 공개 API로 내지 않음(같은 키가 두 자리에 놓이는
> 0-W류 갭을 원천 차단). 정본은 `base/attribute-plan.md` "이름 소유권"
> 절. **아래는 해소 전 원문.**
**이게 지금 유일하게 `base/` 반영을 막고 있는 결정.** 아래 0-A의 **이게 지금 유일하게 `base/` 반영을 막고 있는 결정.** 아래 0-A의
재디스패치 모델은 나머지가 전부 확정됐고, 이 항목 하나만 정해지면 재디스패치 모델은 나머지가 전부 확정됐고, 이 항목 하나만 정해지면
@ -138,21 +148,29 @@ Attribute가 직접 해야 함.**
claimant 단방향이면 충분할 것이라는 방향. claimant 단방향이면 충분할 것이라는 방향.
- 원문 맥락과 기각된 두 중간안(`rawNew` 전용 키, `AttributeGroupKeyHandler` - 원문 맥락과 기각된 두 중간안(`rawNew` 전용 키, `AttributeGroupKeyHandler`
체크포인트)은 `archive/checkpoint-handler-pattern-reversed.md`, 체크포인트)은 `archive/checkpoint-handler-pattern-reversed.md`,
분석은 `research/dispatch-redispatch-diff-plan.md` 5절. 분석은 `archive/dispatch-hintvalue-model-reversed.md` 5절.
**대안 후보(정리해둠)**: (a) 이름별 claimant `Relate`를 Attribute에 **대안 후보(정리해둠)**: (a) 이름별 claimant `Relate`를 Attribute에
국소적으로 — 권고, (b) UB로 두고 문서로만 금지 — 증상이 "조용한 오작동 + 국소적으로 — 권고, (b) UB로 두고 문서로만 금지 — 증상이 "조용한 오작동 +
교차 오염"이라 다른 UB들(즉시 스택오버플로/즉시 error)보다 나빠서 비권장, 교차 오염"이라 다른 UB들(즉시 스택오버플로/즉시 error)보다 나빠서 비권장,
(c) `Dispatch`에 claimant 개념 일반화 — 이번에 걷어낸 방향이라 반대. (c) `Dispatch`에 claimant 개념 일반화 — 이번에 걷어낸 방향이라 반대.
### 0-A. `hintValue` 폐기 → process 하강 중 핸들러 비교 (2026-08-13 여섯 번째 세션, **Attribute 건 외 확정**) ### 0-A. ~~`hintValue` 폐기 → process 하강 중 핸들러 비교~~ **[해소됨, 2026-08-13 열네 번째 세션 — base 반영 완료]** (2026-08-13 여섯 번째 세션)
> **결론**: 모델 그대로 채택되어 `base/dispatch-core-plan.md`(같은 세션에
> `bind-system-plan.md`에서 분리 신설)로 전면 반영. 부수적으로
> `Dispatch.retractFrom`이 4-인자에서 **3-인자**가 됐음(값을 넘기는 경로가
> `Dispatch.process`의 "같은 핸들러" 분기 하나로 통일되어, 외부가 힌트를
> 만들어 넣을 자리 자체가 사라짐). 배너를 달고 있던 7개 문서 전부 갱신
> 완료. 뒤집힌 옛 모델 원문은 `archive/dispatch-hintvalue-model-reversed.md`.
> **아래는 해소 전 원문.**
**검토 결과 사용자 지적이 맞음 — 현행 `hintValue`엔 실제 결함이 있음.** **검토 결과 사용자 지적이 맞음 — 현행 `hintValue`엔 실제 결함이 있음.**
힌트가 "그 자리에 곧 디스패치될 raw 값"이라 `None` 센티널이나 `State`/ 힌트가 "그 자리에 곧 디스패치될 raw 값"이라 `None` 센티널이나 `State`/
`Tween` 같은 래퍼가 그대로 넘어갈 수 있고, 그러면 말단 핸들러의 `Tween` 같은 래퍼가 그대로 넘어갈 수 있고, 그러면 말단 핸들러의
`isTag(hint)` 가드가 거짓이 되어 **깜빡임/재생성 방지가 조용히 꺼짐** `isTag(hint)` 가드가 거짓이 되어 **깜빡임/재생성 방지가 조용히 꺼짐**
(정확성은 유지돼서 지금까지 안 드러났음). 상세 재현·분석·제안은 (정확성은 유지돼서 지금까지 안 드러났음). 상세 재현·분석·제안은
`research/dispatch-redispatch-diff-plan.md`. `archive/dispatch-hintvalue-model-reversed.md`.
**후속 라운드에서 모델은 거의 확정됨** — 래핑 핸들러가 `retractFrom` **후속 라운드에서 모델은 거의 확정됨** — 래핑 핸들러가 `retractFrom`
선행 호출하는 걸 폐기하고, `Dispatch.process` 안에서 **핸들러를 먼저 선행 호출하는 걸 폐기하고, `Dispatch.process` 안에서 **핸들러를 먼저
@ -172,7 +190,7 @@ Attribute가 직접 해야 함.**
**실행 규모**: `base/``bind-system-plan.md`/`tag-plan.md`/`slot-plan.md`/ **실행 규모**: `base/``bind-system-plan.md`/`tag-plan.md`/`slot-plan.md`/
`attribute-plan.md` 의사코드 재작성 + `architecture.md` 소스트리 서술과 `attribute-plan.md` 의사코드 재작성 + `architecture.md` 소스트리 서술과
`ROADMAP.md` M2/M4/M6/M10 체크리스트 — 0-Z 하나만 정해지면 한 번에 옮기면 `ROADMAP.md` M2/M4/M6/M10 체크리스트 — 0-Z 하나만 정해지면 한 번에 옮기면
됨(어디를 어떻게 고칠지는 `research/dispatch-redispatch-diff-plan.md` 6절에 됨(어디를 어떻게 고칠지는 `archive/dispatch-hintvalue-model-reversed.md` 6절에
파일별로 적어둠 — 뒤 둘은 2026-08-13 7차 감사에서 그 목록에 빠져 있던 걸 파일별로 적어둠 — 뒤 둘은 2026-08-13 7차 감사에서 그 목록에 빠져 있던 걸
발견해 추가). **그때까지 `base/`의 현행 `hintValue` 서술이 유효** 발견해 추가). **그때까지 `base/`의 현행 `hintValue` 서술이 유효**
아직 안 옮겼다는 걸 잊고 base만 읽으면 옛 모델로 구현하게 되니 주의. 아직 안 옮겼다는 걸 잊고 base만 읽으면 옛 모델로 구현하게 되니 주의.

View file

@ -1,11 +1,12 @@
# quad-v2 전체 아키텍처 (현재 상태 요약) # quad-v2 전체 아키텍처 (현재 상태 요약)
> **⚠️ [2026-08-13 4차 감사에서 발견] 이 문서는 세션 시작 시 가장 먼저 > **✅ [2026-08-13 열네 번째 세션] 재디스패치 모델(0-A)/Attribute 이름
> 읽는 진입점인데, 아래 소스 트리의 `chains`/`retractFrom`(Dispatch/init.luau > 소유권(0-Z) 확정·반영 완료 — 아래 소스 트리도 갱신됨.** 이전 ⚠️ 배너가
> 항목)과 `retractFrom`에 재귀 위임(Attribute.luau 항목) 서술은 현행 > 경고하던 "옛 모델로 짜게 됨" 위험은 해소. 디스패치 코어는
> (교체 예정) 재-dispatch 모델을 전제로 쓰여 있음 — `question.md` **0-Z** > `base/bind-system-plan.md`에서 **`base/dispatch-core-plan.md`
> 해소 전엔 그대로 구현하면 옛 모델로 짜게 됨. 상세는 > 분리**됐고, `Tag`/`Attribute`는 알고리즘까지 quad-base로 재배치됐음
> `base/bind-system-plan.md` 최상단 배너, `research/dispatch-redispatch-diff-plan.md`. > (엔진 op만 주입). 뒤집힌 옛 모델은
> `archive/dispatch-hintvalue-model-reversed.md`.
**상태**: base — 횡단 결정의 최종 상태 요약. 특정 기능 plan이 아니라 프로젝트 **상태**: base — 횡단 결정의 최종 상태 요약. 특정 기능 plan이 아니라 프로젝트
전체에 걸친 결정이라 완료 개념 없음. 근거가 된 원본 브레인스토밍은 전체에 걸친 결정이라 완료 개념 없음. 근거가 된 원본 브레인스토밍은
@ -78,7 +79,7 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo
방지, 비용은 무시 가능한 수준으로 확인됨). 방지, 비용은 무시 가능한 수준으로 확인됨).
8. **특수 이벤트는 특수 플러깅으로.** `PropertyChangedSignal`, `PropertyChangedEvent ""` 8. **특수 이벤트는 특수 플러깅으로.** `PropertyChangedSignal`, `PropertyChangedEvent ""`
같은 것들은 일반 이벤트 바인드가 아니라 pluggable 바인드 핸들러 중 하나로 같은 것들은 일반 이벤트 바인드가 아니라 pluggable 바인드 핸들러 중 하나로
구현(`base/bind-system-plan.md`). 구현(`base/dispatch-core-plan.md`).
9. **Tracker 미구현.** v1의 소스 변경 감지 자동 재렌더 기능(hot-reload watcher, 9. **Tracker 미구현.** v1의 소스 변경 감지 자동 재렌더 기능(hot-reload watcher,
실제로는 `.claude/initreq/quad/src/tracker.lua` — v1에서도 이미 `exports.lua` 실제로는 `.claude/initreq/quad/src/tracker.lua` — v1에서도 이미 `exports.lua`
연결 안 된 죽은 코드였음, `reference/quad-v1-architecture.md` 참고)은 렌더 연결 안 된 죽은 코드였음, `reference/quad-v1-architecture.md` 참고)은 렌더
@ -106,7 +107,7 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo
`BaseModule` 테이블을 만들어 팩토리로 채우는 것뿐. Dispatch의 handler `BaseModule` 테이블을 만들어 팩토리로 채우는 것뿐. Dispatch의 handler
레지스트리를 포함해 지금 module-level state로 사는 모든 것(`_initializedBy` 레지스트리를 포함해 지금 module-level state로 사는 모든 것(`_initializedBy`
마커, Dispatch 레지스트리 등)이 자동으로 테이블별 스코핑됨 — 상세 근거는 마커, Dispatch 레지스트리 등)이 자동으로 테이블별 스코핑됨 — 상세 근거는
`base/bind-system-plan.md`의 "Dispatch는 프리미티브가 아니다" 절. `base/dispatch-core-plan.md`의 "Dispatch는 프리미티브가 아니다" 절.
14. **pluggable 초기화는 팩토리 함수로.** rbvm처럼 네임스페이스 하나하나 수동 14. **pluggable 초기화는 팩토리 함수로.** rbvm처럼 네임스페이스 하나하나 수동
init 하는 방식(`base/lifecycle-pattern.md` 5번 항목 참고)은 피하고, init 하는 방식(`base/lifecycle-pattern.md` 5번 항목 참고)은 피하고,
`InitRoblox(Module)` 같은 팩토리 함수가 생성된 모듈을 뮤테이션하는 도구를 `InitRoblox(Module)` 같은 팩토리 함수가 생성된 모듈을 뮤테이션하는 도구를
@ -135,6 +136,15 @@ RbxUtil`이 정확히 이 패턴(루트 하나로 통합 개발/테스트, 서
**pluggable 디스패치 엔진 자체도 "인터페이스"로 base가 소유**한다(엔진마다 **pluggable 디스패치 엔진 자체도 "인터페이스"로 base가 소유**한다(엔진마다
큰 구현을 중복하지 않기 위함 — rbvm이 relation을 하나로 통합하려 했던 것과 큰 구현을 중복하지 않기 위함 — rbvm이 relation을 하나로 통합하려 했던 것과
같은 동기). `quad-roblox`는 그 인터페이스의 **실제 구현체**만 제공. 같은 동기). `quad-roblox`는 그 인터페이스의 **실제 구현체**만 제공.
**[2026-08-13 열네 번째 세션 확장] 이 원칙은 "값 타입"이 아니라 "부기가
엔진 지식을 요구하는가"로 가른다** — `Tag`의 참조 카운트와 `Attribute`
이름 claim/그룹 위임은 엔진과 무관한 순수 부기라 **핸들러째로 quad-base**,
백엔드는 `addTag`/`removeTag`/`setAttribute` 세 op만 주입한다(웹에도
`className`/`data-*`라는 대응물이 있어서, 그러지 않으면 같은 알고리즘이
백엔드마다 복제됨). 반대로 Property/Event/OnChange는 Reflection·시그널
자체가 로직이라 그대로 백엔드 소속 — 상세 기준과 op 시그니처는
`base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진
op" 절.
``` ```
quad/ quad/
@ -148,15 +158,19 @@ quad/
│ ├── Store.luau # source 집합체, dot-access로 Source 그대로 반환 │ ├── Store.luau # source 집합체, dot-access로 Source 그대로 반환
│ ├── Blocker.luau # 값 기반 emit 지연/합치기(`base/blocker-plan.md`), State/Source와 밀접 연관돼 같은 위치 │ ├── Blocker.luau # 값 기반 emit 지연/합치기(`base/blocker-plan.md`), State/Source와 밀접 연관돼 같은 위치
│ ├── Modifier.luau # flatten-before-dispatch, immutable 체이닝, 제네릭 `__index` 필드 setter 합성 + `:Apply`/`:Peek`/`Overridden`(`base/modifier-plan.md`) │ ├── 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`), CollectionService 글루는 quad-roblox Handlers/Tag.luau(`base/tag-plan.md`, 2026-08-08 세 번째 세션) │ ├── 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`, `Tag`와 동형) — `SetAttribute` 글루는 quad-roblox Handlers/Attribute.luau(`base/attribute-plan.md`, 2026-08-11 아홉 번째 세션). 단일 키(`AttributeKey<<T>>`)는 값 타입 레이어 없이 quad-roblox 단독 소속(아래) │ ├── 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 세션 재설계) │ ├── 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`) │ ├── Effect.luau # `Effect(fn, state?)` — state 없으면 설치1회+leaf사망시 정리, 있으면 State.Observer를 조합해 재실행(`base/effect-plan.md`)
│ ├── Dispatch/ │ ├── Dispatch/
│ │ ├── init.luau # process 엔진(반환값=retract 클로저), isHandlable 우선순위 스캔, `chains`(inst,k별 인덱스 배열)+`retractFrom`(`bind-system-plan.md` "Dispatch 체인" 절, 2026-08-08 세 번째 세션 신설, 2026-08-13 다섯 번째 세션 인덱스 기반 전면 재설계) │ │ ├── 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 클로저를 반환) │ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process — process가 자기 retract 클로저를 반환)
│ │ ├── StoreBind.luau # store 값 재귀 재실행 로직(범용, 엔진 무관) │ │ ├── StoreBind.luau # store 값 재귀 재실행 로직(범용, 엔진 무관)
│ │ ├── Leaf.luau # (i:number, v=Ref/Observer/PreRef) children-array leaf 매칭 Handler, StoreBind와 같은 층위(범용/엔진무관, 2026-08-08 두 번째 세션 확정) │ │ ├── Leaf.luau # (i:number, v=Ref/Observer/PreRef) children-array leaf 매칭 Handler, StoreBind와 같은 층위(범용/엔진무관, 2026-08-08 두 번째 세션 확정)
│ │ ├── Tag.luau # TagHandler — 이름별 참조 카운트(`tagNameMap`), 실제 호출은 주입된 addTag/removeTag(inst, {string}). HANDLER_PRIORITY_FALLBACK으로 등록(`base/tag-plan.md`, 2026-08-13 열네 번째 세션 base로 이동)
│ │ ├── AttributeKey.luau # AttributeKeyHandler — 이름 claim(`nameClaims`, 소유권 충돌 즉시 error) + 주입된 setAttribute(inst,name,v) 호출, `None`→nil은 재디스패치로 자동(`base/attribute-plan.md` "이름 소유권" 절)
│ │ ├── Attribute.luau # AttributeGroupHandler — 그룹 전용 키(비공개 GetKey)로 이름마다 AttributeKey 경로에 인덱스 1 위임, 클로저가 자기 키 전부 retractFrom(`base/attribute-plan.md` "메커니즘" 절)
│ │ └── Slot.luau # add/remove/clear 재조정 로직(추상 자식 참조 기준) │ │ └── Slot.luau # add/remove/clear 재조정 로직(추상 자식 참조 기준)
│ ├── 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)`/`canExecute(inst,value)` 탑레벨 함수 "인터페이스"(타입/계약만), 내부는 Relate 사용(`base/lifecycle-pattern.md`) │ ├── LifetimeHandle.luau # `bindLifetime(inst,value)`/`canExecute(inst,value)` 탑레벨 함수 "인터페이스"(타입/계약만), 내부는 Relate 사용(`base/lifecycle-pattern.md`)
@ -166,15 +180,13 @@ quad/
└── quad-roblox/ └── quad-roblox/
├── wally.toml ├── wally.toml
└── src/ └── src/
├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) ├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) — 주입 대상엔 bindLifetime/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이면 삭제) (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절)
├── LifetimeHandle.luau # bindLifetime/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetStrong으로 gcconn/gchold 저장(`base/lifecycle-pattern.md`). Relate 자체는 순수 Lua라 quad-roblox 쪽 재구현 없음(quad-base 그대로 재사용) ├── LifetimeHandle.luau # bindLifetime/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetStrong으로 gcconn/gchold 저장(`base/lifecycle-pattern.md`). Relate 자체는 순수 Lua라 quad-roblox 쪽 재구현 없음(quad-base 그대로 재사용)
├── Handlers/ ├── Handlers/
│ ├── Property.luau # 일반 프로퍼티 세팅 + `isTween(realv)` 분기(3-상태 릴레이션 슬롯 `RobloxTween|true|nil`, hasBeenSet 억제, override 정책) — 구 `Handlers/Tween.luau`(높은 우선순위 store-bind 핸들러)는 폐기(`archive/tween-special-bind-key-reversed.md`) │ ├── Property.luau # 일반 프로퍼티 세팅 + `isTween(realv)` 분기(3-상태 릴레이션 슬롯 `RobloxTween|true|nil`, hasBeenSet 억제, override 정책) — 구 `Handlers/Tween.luau`(높은 우선순위 store-bind 핸들러)는 폐기(`archive/tween-special-bind-key-reversed.md`)
│ ├── Event.luau # ReflectionService 기반 자동 판별 │ ├── Event.luau # ReflectionService 기반 자동 판별
│ ├── OnChange.luau # `OnChange(name)` DI 키 팩토리+Handler, `GetPropertyChangedSignal` 바인딩 + 이름별 weak 캐시(`AttributeKey`와 동일 기법, `base/onchange-plan.md`, 2026-08-10 세션) │ ├── OnChange.luau # `OnChange(name)` DI 키 팩토리+Handler, `GetPropertyChangedSignal` 바인딩 + 이름별 weak 캐시(`AttributeKey`와 동일 기법, `base/onchange-plan.md`, 2026-08-10 세션)
│ ├── AttributeKey.luau # 단일 키(`AttributeKey<<T>>(name)`/`BooleanAttribute`류) DI 키 팩토리+Handler, `SetAttribute`/`None` 지우기 + 이름별 weak 캐시(동등성 보장, `base/attribute-plan.md` "동등성" 절)
│ ├── Attribute.luau # 그룹(`Attribute(store1, store2, ...)`) process — 이름 집합 diff만 자체 로직(반환 클로저에 캡처, 별도 Relate 불필요), 실제 `SetAttribute`/구독은 공개 `AttributeKey(name)`으로 항상 인덱스 1부터 `Dispatch.process`/`retractFrom`에 재귀 위임(단일 키 경로 재사용, 중복 구현 없음) — 값 타입/API는 quad-base Attribute.luau(`base/attribute-plan.md`)
│ ├── Tag.luau # CollectionService 글루만(process, 반환 클로저가 정리 담당) — 값 타입/API는 quad-base Tag.luau(`base/tag-plan.md`)
│ ├── Slot.luau # base Slot 재조정 로직의 실제 적용/해제(Instance Parent 조작) │ ├── Slot.luau # base Slot 재조정 로직의 실제 적용/해제(Instance Parent 조작)
│ └── InstanceChild.luau # k:number, v:Instance — 중첩 인스턴스 자식(예: Frame { Frame {} }) │ └── InstanceChild.luau # k:number, v:Instance — 중첩 인스턴스 자식(예: Frame { Frame {} })
├── Animate.luau # `Animate(info)` 편의 콤비네이터 — `factory(self)->State`, `:Apply`로 붙임(내부는 `:Compute`/`Tween{...}` 조합), base 프리미티브 아님(`base/tween-plan.md`) ├── Animate.luau # `Animate(info)` 편의 콤비네이터 — `factory(self)->State`, `:Apply`로 붙임(내부는 `:Compute`/`Tween{...}` 조합), base 프리미티브 아님(`base/tween-plan.md`)
@ -225,7 +237,7 @@ existing-instance-bind는 여전히 `research/`에 남아있고 이 구조 확
전용 소유물인가?"로 물으면 됨 — 그렇다면 대문자(생성자/메서드/그 전용 소유물인가?"로 물으면 됨 — 그렇다면 대문자(생성자/메서드/그
타입의 정적 결합 함수), 아니면(범용 유틸이거나 프리미티브가 아닌 엔진 타입의 정적 결합 함수), 아니면(범용 유틸이거나 프리미티브가 아닌 엔진
소속) 소문자. `Dispatch`/`Brand`가 프리미티브가 아닌 이유는 소속) 소문자. `Dispatch`/`Brand`가 프리미티브가 아닌 이유는
`base/bind-system-plan.md`의 "Dispatch는 프리미티브가 아니다" 절/ `base/dispatch-core-plan.md`의 "Dispatch는 프리미티브가 아니다" 절/
`base/store-semantics.md`의 "세 번째 카테고리 — Handler" 절 참고. `base/store-semantics.md`의 "세 번째 카테고리 — Handler" 절 참고.
## 코드 스타일 — Luau 문법 관례: `if-then-else`/`const` (2026-08-12 세션 신설) ## 코드 스타일 — Luau 문법 관례: `if-then-else`/`const` (2026-08-12 세션 신설)
@ -234,7 +246,7 @@ existing-instance-bind는 여전히 `research/`에 남아있고 이 구조 확
간주해 `and`/`or`로 "고치지" 말 것.** 2021년 10월 Luau에 정식 도입된 간주해 `and`/`or`로 "고치지" 말 것.** 2021년 10월 Luau에 정식 도입된
표현식 문법(공식 릴리스 노트: <https://luau.org/news/2021-10-31-luau-recap-october-2021/#if-then-else-expression>) 표현식 문법(공식 릴리스 노트: <https://luau.org/news/2021-10-31-luau-recap-october-2021/#if-then-else-expression>)
`cond and truthyOnly or fallback` 삼항 관용구와 달리 가운데 값이 `cond and truthyOnly or fallback` 삼항 관용구와 달리 가운데 값이
falsy(`nil`/`false`)여도 정확하게 동작함(`bind-system-plan.md`의 falsy(`nil`/`false`)여도 정확하게 동작함(`dispatch-core-plan.md`의
`Dispatch.retractUnder`(현 `retractFrom`) 정정 사례가 실제 버그 예시). **[강화, 2026-08-12 `Dispatch.retractUnder`(현 `retractFrom`) 정정 사례가 실제 버그 예시). **[강화, 2026-08-12
세션 후속] `cond and x or y` 삼항 관용구는 전면 금지 — `if-then-else` 세션 후속] `cond and x or y` 삼항 관용구는 전면 금지 — `if-then-else`
쓸 것, 가운데 값이 항상-truthy임이 보장돼도 예외 없음.** 처음엔 그 쓸 것, 가운데 값이 항상-truthy임이 보장돼도 예외 없음.** 처음엔 그
@ -265,8 +277,8 @@ property bag + property별 변경 시그널 정도만 흉내내고, `IsA()`/클
Studio/엔진 없이 테스트(Vide가 실제로 이렇게 CI에 물려놓음) — Fusion처럼 Studio/엔진 없이 테스트(Vide가 실제로 이렇게 CI에 물려놓음) — Fusion처럼
Studio 안에서만 도는 방식은 채택 안 함. 근거: quad-base 코어(Store/State/ Studio 안에서만 도는 방식은 채택 안 함. 근거: quad-base 코어(Store/State/
Source/Modifier/Slot, 디스패치 엔진)는 이미 `inst``any`로 취급하고 Source/Modifier/Slot, 디스패치 엔진)는 이미 `inst``any`로 취급하고
Instance 특정 동작을 전혀 참조하지 않도록 설계돼 있어(`bind-system-plan.md` Instance 특정 동작을 전혀 참조하지 않도록 설계돼 있어(`dispatch-core-plan.md`
"inst가 항상 Roblox Instance일 필요는 없음" 절), mock이 실제 Roblox 충실도를 "확정된 디스패치 모델" 절의 "`inst`가 항상 살아있는 엔진 객체일 필요는 없음"), mock이 실제 Roblox 충실도를
가질 이유가 없음. 가질 이유가 없음.
**스코프는 "정적 디버깅"으로 한정** — **사용자 확정**: mock으로 확인하려는 **스코프는 "정적 디버깅"으로 한정** — **사용자 확정**: mock으로 확인하려는
@ -316,5 +328,5 @@ pull-recompute(`Get()` 시점) — Fusion식 eager 노드 없이도 다이아몬
참고, 전체 색인은 `.claude/README.md`. 바인드 디스패치/Slot/모듈 참고, 전체 색인은 `.claude/README.md`. 바인드 디스패치/Slot/모듈
라이프사이클/Modifier/컴포넌트화(컴포넌트 경계 modifier/Ref 전달 포함)는 라이프사이클/Modifier/컴포넌트화(컴포넌트 경계 modifier/Ref 전달 포함)는
위 "구현 착수" 섹션대로 확정되어 `.claude/base/`로 승격됨 위 "구현 착수" 섹션대로 확정되어 `.claude/base/`로 승격됨
(`bind-system-plan.md`/`module-lifecycle-plan.md`/`slot-plan.md`/ (`bind-system-plan.md`/`dispatch-core-plan.md`/`module-lifecycle-plan.md`/
`modifier-plan.md`/`component-composition-plan.md`). `slot-plan.md`/`modifier-plan.md`/`component-composition-plan.md`).

View file

@ -1,27 +1,31 @@
# Attribute — 단일 키(`AttributeKey`)와 그룹(`Attribute(...)`) 두 프리미티브 # Attribute — 단일 키(`AttributeKey`)와 그룹(`Attribute(...)`) 두 프리미티브
> **⚠️ [2026-08-13 여섯 번째 세션] 이 문서의 `hintValue`/`retractFrom` 선행 > **✅ [2026-08-13 열네 번째 세션] `question.md` 0-Z(이름 소유권) 확정 +
> 호출 서술은 곧 교체될 예정 — 아직 반영 안 됨.** 힌트가 `None` 센티널이나 > 하강 diff 재디스패치 반영 완료 — 이 문서는 이제 최신 모델을 서술합니다.**
> `State`/`Tween` 래퍼로 오염돼 말단 핸들러의 `isX(hint)` 가드를 거짓으로 > 이전 ⚠️ 배너가 예고하던 교체가 전부 끝났음: (1) 그룹은 **자기 전용 키**로
> 만들고 깜빡임/재생성 방지를 조용히 끄는 결함이 확인됐고, 대체 모델 > 위임하고(비공개 `GetKey`), 이름 소유권은 **`AttributeKeyHandler`의 이름
> (**래핑 핸들러의 `retractFrom` 선행 호출 폐기 + `Dispatch.process` > claim**이 판정(아래 "이름 소유권" 절), (2) `retractFrom` 선행 호출은
> 핸들러를 먼저 비교**)까지 거의 확정됐음 — 다만 `question.md` **0-Z** > 폐기되고 `Dispatch.process`가 핸들러를 먼저 비교
> (Attribute 이름 소유권) 하나가 남아 아직 옮기지 않음. **여기 적힌 대로 > (`base/dispatch-core-plan.md`), (3) **값 타입·알고리즘 전부 quad-base
> 구현하면 옛 모델로 짜게 됨** — 반드시 > 소속으로 재배치**되고 `setAttribute` op만 백엔드가 주입(아래 "패키지
> `research/dispatch-redispatch-diff-plan.md`를 먼저 읽을 것. > 배치" 절). 뒤집힌 옛 모델 원문은
> `archive/dispatch-hintvalue-model-reversed.md`.
**상태**: base — 단일 키 메커니즘/`None`/`retract` 동작과 타입 파라미터화는 **상태**: base — 단일 키 메커니즘/`None`/`retract` 동작과 타입 파라미터화는
전부 확정(2026-08-09 열한 번째 세션, **2026-08-12 세션 후속에서 전부 확정(2026-08-09 열한 번째 세션, **2026-08-12 세션 후속에서
`retract` 완전 no-op화 + 그룹 청소 정책 전면 재정정 — 아래 "메커니즘"/ `retract` 완전 no-op화 + 그룹 청소 정책 전면 재정정 — 아래 "메커니즘"/
"그룹 `Attribute(...)`" 절이 최신**). **[2026-08-13 세션, 하루 안에서 "그룹 `Attribute(...)`" 절이 최신**). **[2026-08-13 세션, 하루 안에서
두 차례 재설계]** 그룹/직접 쓰기 이름 충돌 방지 방식이 `rawNew`+`owners` 네 차례 재설계 — 지금은 마지막 것이 확정]** 그룹/직접 쓰기 이름 충돌
수동 레지스트리 → `AttributeGroupKeyHandler` 체크포인트(`Dispatch. 방지 방식이 `rawNew`+`owners` 수동 레지스트리 → `AttributeGroupKeyHandler`
processAs`/`retractSelfAndUnder`) → **최종적으로 `Dispatch` 자체의 체크포인트(`Dispatch.processAs`/`retractSelfAndUnder`) → 공개
인덱스 기반 재설계(`base/bind-system-plan.md` "Dispatch 체인" 절)에 `AttributeKey(name)`+Dispatch 점유 체크 → **최종적으로 그룹 전용 키
올라타 체크포인트도 필요 없어짐**(공개 `AttributeKey(name)`으로 항상 (비공개 `GetKey`) + `AttributeKeyHandler`의 이름 claim**(2026-08-13
인덱스 1에 직접 위임, 점유 체크가 소유권 충돌 감지를 대신함) — 아래 열네 번째 세션, `question.md` 0-Z 확정). 세 번째 안이 다시 뒤집힌
"이름 소유권"/"메커니즘" 절이 최신, 중간 버전은 `archive/ 이유는 하강 diff 재디스패치에선 **두 그룹이 똑같이 `StoreBind`로 보여
checkpoint-handler-pattern-reversed.md`에 보존. **[2026-08-11 아홉 번째 세션 추가]** "같은 핸들러"로 판정**되므로 점유 체크가 성립하지 않기 때문 — 아래
"이름 소유권"/"메커니즘" 절이 최신, 중간 버전들은 `archive/
checkpoint-handler-pattern-reversed.md`와
`archive/dispatch-hintvalue-model-reversed.md`에 보존. **[2026-08-11 아홉 번째 세션 추가]**
Store 여러 개를 한 번에 attribute로 묶어 바인드하는 그룹 `Attribute(...)` Store 여러 개를 한 번에 attribute로 묶어 바인드하는 그룹 `Attribute(...)`
프리미티브 신설, 이름 충돌 방지를 위해 기존 단일 키 생성자를 프리미티브 신설, 이름 충돌 방지를 위해 기존 단일 키 생성자를
`Attribute<<T>>``AttributeKey<<T>>`로 리네임(잠정 확정 — 최종 이름은 `Attribute<<T>>``AttributeKey<<T>>`로 리네임(잠정 확정 — 최종 이름은
@ -119,100 +123,129 @@ end
"a"`가 외부에 관찰되는 것도 의도적으로 허용 가능한 동작(사용자 확인). "a"`가 외부에 관찰되는 것도 의도적으로 허용 가능한 동작(사용자 확인).
`base/onchange-plan.md` "확정" 절 참고. `base/onchange-plan.md` "확정" 절 참고.
### 메커니즘, `None`, `retract` — 전부 확정 (2026-08-07 여덟 번째 세션) ### 메커니즘, `None`, 반환 클로저 — 전부 확정 (2026-08-07 여덟 번째 세션, 2026-08-13 열네 번째 세션에 이름 claim 추가)
타입 파라미터화 이름과 무관하게 런타임 동작은 확정: 타입 파라미터화 이름과 무관하게 런타임 동작은 확정:
- `process(inst, k, v, index)``inst:SetAttribute(name, v)`가 사실상 - `process(inst, k, v, index)``setAttribute(inst, name, v)`가 사실상
전부, **`v`가 뭐든(실제 값이든 `nil`이든) 무조건 그대로 호출** — 일반 전부, **`v`가 뭐든(실제 값이든 `nil`이든) 무조건 그대로 호출** — 일반
프로퍼티 핸들러와 완전히 동일한 무조건 set. **Attribute는 `None` 프로퍼티 핸들러와 완전히 동일한 무조건 set. **Attribute는 `None`
가장 깔끔한 사례** — Roblox API 자체가 `SetAttribute(name, nil)` 가장 깔끔한 사례** — 주입 op의 계약 자체가 `setAttribute(inst,name,nil)`
"그 Attribute 엔트리를 지운다"는 뜻으로 네이티브 지원하므로, `None → = "그 Attribute 엔트리를 지운다"이므로(Roblox `SetAttribute`의 네이티브
nil` 재디스패치(`base/bind-system-plan.md`의 `None` 센티널 절)가 동작 그대로, `base/dispatch-core-plan.md` "base가 소유하는 핸들러와
도착했을 때 handler가 **아무 특별 처리도 없이** `inst:SetAttribute(name, 주입되는 엔진 op" 절), `None → nil` 재디스패치(같은 문서의 `None`
nil)`을 그대로 호출하면 끝 — UICorner 숏핸드처럼 "만들어둔 자식을 센티널 절)가 도착했을 때 handler가 **아무 특별 처리도 없이** 그대로
수동으로 찾아 지우는" 로직조차 필요 없음. 호출하면 끝 — UICorner 숏핸드처럼 "만들어둔 자식을 수동으로 찾아
- **반환하는 클로저는 완전 no-op — [재정정, 2026-08-12 세션 후속] "매번 지우는" 로직조차 필요 없음.
불리지만 대부분 no-op"이라던 직전 서술도 틀렸음, "대부분"이 아니라 - **반환하는 클로저는 `setAttribute`를 절대 호출하지 않음** — attribute를
"항상"** — 일반 프로퍼티 핸들러(반환 클로저가 완전 무조건 no-op, 지우는 유일한 경로는 `process(inst,k,nil,index)`(`None`이든, State가
`bind-system-plan.md` "일반 프로퍼티는 애초에 'unset' 개념이 없음")와 스스로 `nil`로 바뀌든) 뿐(2026-08-12 세션 후속 확정, 그대로 유지).
완전히 같은 성격으로 재정정. **`AttributeKeyHandler`가 반환하는 **[2026-08-13 열네 번째 세션] 다만 "완전 no-op"은 더 이상 아님** — 아래
클로저는 `SetAttribute`를 절대 호출하지 않음** — attribute를 지우는 "이름 소유권" 절의 이름 claim을 반납하는 한 줄이 들어감. 엔진에 대한
유일한 경로는 `process(inst,k,nil,index)`(`None`이든, State가 스스로 부작용은 여전히 0이고(관측 가능한 attribute 값은 안 건드림), 반납하는
`nil`로 바뀌든) 뿐. 이전 버전("이름이 사라질 때(`v==nil`)만 retract가 건 순수 부기라 아래 "`a→nil→b` 깜빡임" 위험도 그대로 없음. 이전
`SetAttribute(name,nil)`을 호출")은 두 가지 문제가 있었음 — (1) 버전("이름이 사라질 때(`v==nil`)만 클로저가 `SetAttribute(name,nil)`
이 클로저 안에 관측 가능한 부작용이 생겨 `bind-system-plan.md` 호출")이 기각된 이유는 그대로 유효: (1) 클로저 안에 관측 가능한
"이 클로저는 구조적 팝만, process 트리거 금지" 일반 규칙과 어긋나는 부작용이 생겨 "이 클로저는 구조적 팝만" 일반 규칙과 어긋남,
성격의 코드가 됨, (2) 그룹이 survivor 이름에 재위임할 때 그 시점에 (2) 그룹이 survivor 이름에 재위임할 때 `SetAttribute`가 잘못 끼어들어
`SetAttribute`가 잘못 끼어들 수 있는 경로가 생겨 `a→nil→b` 깜빡임 `a→nil→b` 깜빡임이 생길 수 있었음.
위험(사용자 지적) — 클로저가 완전 no-op이면 이 경로 자체가 물리적으로
없어짐.
- store-bind 가능(일반 프로퍼티와 동일하게 취급, `Store<T>`/`State<T>` - store-bind 가능(일반 프로퍼티와 동일하게 취급, `Store<T>`/`State<T>`
값도 받음). 값도 받음).
### 이름 소유권 — 그룹/직접 쓰기 충돌 방지 (2026-08-12 열 번째 세션, 2026-08-13 다섯 번째 세션 전면 재정정) ### 이름 소유권 — 그룹 전용 키 + 이름 claim (2026-08-12 열 번째 세션 신설, 2026-08-13 열네 번째 세션 확정 = `question.md` 0-Z)
**문제**: `AttributeKey(name)`이 이름별 weak 캐시로 항상 같은 객체를 **문제**: 같은 attribute 이름을 **서로 다른 두 자리가 동시에 관리**할 수
리턴하고, 그룹 `Attribute(...)`가 그 경로를 그대로 재사용(아래 "메커니즘" 있음 — 해시파트 직접 쓰기 `[AttributeKey "name"] = value` vs 배열파트
절)하다 보니, **서로 다른 원래 위치(해시파트 직접 쓰기 `[AttributeKey `Attribute(store)`, 또는 서로 다른 두 `Attribute(...)` 그룹. Modifier
"name"]=value` vs 배열파트 `Attribute(store)`, 또는 서로 다른 두 필드는 정적이라 override로 이미 해소되지만(같은 해시 키는 한 Modifier
`Attribute(...)` 그룹)가 같은 이름을 동시에 관리하려 하면 정확히 같은 안에 하나뿐), 그룹의 이름 집합은 런타임에 동적이라 그 해소망 밖에 있음.
`(inst, k=AttributeKey(name))` 자리로 수렴해 조용히 마지막 쓰기가
이기는 충돌이 생김.** Modifier 필드는 정적이라 override로 이미 해소되지만
(같은 해시 키는 한 Modifier 안에 하나뿐), 그룹의 이름 집합은 런타임에
동적이라 이 해소망 밖에 있음.
**[역사, 2026-08-13 세션 안에서 두 번 뒤집힘]** 첫 버전(`rawNew`로 그룹 **왜 Dispatch가 대신 잡아줄 수 없는가**: 옛 모델에선 그룹이 공개
전용 키를 만들고 `owners` Relate로 이름별 소유권을 수동 추적)은 "그룹이 `AttributeKey(name)`(이름별 weak 캐시라 항상 같은 객체)으로 위임했고,
이름을 놓았다 나중에 같은 그룹이 그 이름을 다시 포함하면 자기 자신과 같은 `(inst,k)` 인덱스 1을 두 소유자가 노리면 `Dispatch.process`의 점유
충돌"하는 실제 버그가 있었음(소유권 반납이 `process``v==nil` 분기에만 체크가 error를 냈음. **하강 diff 재디스패치에선 이게 성립하지 않음**
있어서, 그룹이 이름을 통째로 놓는 경로는 그 분기를 안 타서 옛 소유권 그룹 A가 등록해둔 인덱스 1의 핸들러는 `StoreBind`이고 그룹 B가 넘기는
기록이 안 지워짐). 두 번째 버전(`AttributeGroupKeyHandler`라는 스캔 `sourceB``StoreBind`에 매치되므로 **"같은 핸들러"로 판정되어 조용히
불가 체크포인트 핸들러를 `Dispatch.processAs`로 명시 push, 소유권 충돌을 갈아탐**. 게다가 나중에 A의 클로저가 자기 이름들을 `retractFrom`할 때
Dispatch의 재진입 가드에 얹어 감지)은 이 버그를 고치긴 했으나, 같은 날 **B의 바인딩을 대신 철거**함(교차 오염) — 예전의 "조용한 last-write-wins"가
다섯 번째 세션에 `Dispatch` 자체가 `chains`를 핸들러 identity가 아니라 그대로 돌아옴.
**인덱스**로 추적하도록 재설계되며(`base/bind-system-plan.md` "Dispatch
체인" 절) 체크포인트가 하던 일 자체가 통째로 불필요해짐 — 원문·역전
이유는 `archive/checkpoint-handler-pattern-reversed.md`.
**최종(세 번째 버전) — 체크포인트도 `owners`도 없이, 항상 인덱스 1부터 **확정된 해법(2026-08-13 열네 번째 세션) — 두 조각**:
직접 위임.** **[캐비엇, 2026-08-13 여섯 번째 세션] 여기서 "최종"은 *현행
`hintValue` 모델 기준*이고, 이 주제 자체가 `question.md` **0-Z**로 다시 1. **그룹은 이름마다 자기 전용 키를 쓴다(비공개 `GetKey`)**. 그룹 값
열려 있음** — 문서 상단 ⚠️ 배너가 예고하는 "하강 diff" 재디스패치 모델에선 객체별·이름별로 메모이즈된 `AttributeKey`를 만들어 그걸로 위임 →
그룹 A/B가 둘 다 `StoreBind`로 보여 "같은 핸들러"로 판정되므로 **아래 점유 그룹 A와 그룹 B와 직접 쓰기가 **서로 다른 체인**을 갖게 되어, 한
체크만으로는 그룹↔그룹 충돌을 못 잡음**(`research/dispatch-redispatch-diff-plan.md` 소유자의 철거가 다른 소유자의 바인딩을 건드리는 **교차 오염이
5절). 0-Z가 정해지면 이 절이 네 번째 버전으로 다시 갱신됨. 그룹이 이름마다 공개 `AttributeKey(name)`으로 그냥 구조적으로 불가능**해짐.
`Dispatch.process(inst, key, source, 1)`를 부르면 끝 — **"인덱스 1이 이미 2. **이름 자체의 소유권은 `AttributeKeyHandler`가 이름 claim으로 판정**.
점유돼 있는가"라는 `Dispatch.process` 자신의 점유 체크가 소유권 충돌 `(inst, name) → 지금 그 이름을 잡고 있는 키 객체``Relate` 하나로
감지를 그대로 대신함**: 다른 그룹이나 직접 쓰기가 이미 그 이름을 들고, 다른 키 객체가 같은 이름에 들어오면 **즉시 error**.
점유했다면, 이 호출은(체크포인트를 거칠 필요도 없이) 곧바로 "이미
점유됨" error를 냄. `AttributeKeyHandler`도 다시 완전 무상태로 되돌아감: **왜 이 조합인가 — 대안 (a)(그룹 안에 이름별 claimant `Relate`)로는 부족**:
(a)는 그룹↔그룹은 잡지만 **그룹↔직접 쓰기를 못 잡음**. 직접 쓰기는 그룹
코드를 아예 안 지나가서 그룹 쪽 레지스트리에 등록될 자리가 없고, 두
경로가 실제로 만나는 유일한 지점인 `AttributeKeyHandler`에서는 공개 키를
쓰는 한 `k`가 **같은 객체**라 소유자를 구분할 방법이 원천적으로 없기
때문. 전용 키가 있어야 비로소 "이 이름을 지금 누가 잡고 있나"를 **키
identity 하나로** 판정할 수 있고, 그러면 claim 로직이 그룹을 알 필요도
없어짐(그룹인지 직접 쓰기인지 구분 안 함 — 소유자 종류가 늘어도 그대로
동작). 후보 (b)(UB로 두고 문서화만)는 증상이 "조용한 오작동+교차 오염"이라
비권장으로 기각, (c)(`Dispatch`에 claimant 일반화)는 "Dispatch는 diff만
한다"는 방향과 어긋나 기각.
```lua ```lua
-- AttributeKeyHandler(quad-roblox) — 완전 무상태, 소유권 추적 없음 -- AttributeKeyHandler(quad-base) — 이름 claim만 자기 상태로 가짐
local nameClaims = Relate() -- {[inst(weak)] = {[name] = key(strong)}}
function AttributeKeyHandler.process(inst, k, v, index) function AttributeKeyHandler.process(inst, k, v, index)
inst:SetAttribute(k.Name, v) -- v가 nil이든 아니든 무조건 — 일반 프로퍼티와 완전히 동일 local claims = nameClaims:GetStrong(inst)
return function() end -- 지울 게 없음 — SetAttribute는 오직 process(inst,k,nil)로만 if not claims then
claims = {}
nameClaims:SetStrong(inst, claims)
end
local cur = claims[k.Name]
if cur ~= nil and cur ~= k then
error(`attribute "{k.Name}" is already bound by another owner`)
end
claims[k.Name] = k
setAttribute(inst, k.Name, v) -- v가 nil이든 아니든 무조건(위 "메커니즘" 절)
return function()
-- 엔진 부작용 없음 — claim 반납만. "내가 실제로 물러날 때만" 지움
-- (`dispatch-core-plan.md` "Handler 작성 체크리스트" 4번).
if claims[k.Name] == k then
claims[k.Name] = nil
end
end
end end
``` ```
- **해제 → 재클레임 순서는 `Dispatch`가 보장함.** 같은 핸들러 재프로세스는
`slot.retractor(v)``h.process(...)` 순서이고, 핸들러가 바뀌는 경우는
`retractFrom``process` 순서(`base/dispatch-core-plan.md` "Dispatch
체인" 절) — 어느 경로든 **옛 claim 반납이 새 claim보다 먼저**라 자기
자신과 충돌하는 일이 없음. `5 → None → 5`처럼 체인 깊이가 오가는
경우도 인덱스가 바뀌는 자리에서 `retractFrom`이 먼저 돌므로 동일.
- **`GetKey`는 공개 API가 아님(사용자 확정)** — 그룹 값 객체 바깥으로
키를 반출하면, 사용자가 그 키를 다른 자리에 다시 놓아 **같은 키가 두
자리에서 수렴**할 수 있고 그건 claim(키 identity 기준)으로도 잡히지
않음(0-W "같은 `Ref` 객체 이중 배치"와 같은 형태의 갭). 비공개로
두면 이 경로 자체가 없음 — 구현상으로도 그룹 핸들러 안의
`Relate<그룹 값 → {[name] = key}>` 또는 클로저 캡처면 충분하고, base의
`Attribute` 값 객체 공개 표면에는 아무것도 안 늘어남.
- **에러 메시지는 도메인 언어로** — 옛 모델의 일반 점유 error("이 인덱스는
이미 점유돼 있음")와 달리 여기선 이름을 그대로 찍을 수 있음.
`base/dispatch-core-plan.md`가 "상세 에러가 필요하면 호출부가 도메인
언어로 다시 던지는 건 자유"라고 남겨둔 자리를 이게 채움.
- **직접 리터럴 쓰기**(`[AttributeKey<<T>> "name"] = value`)는 공개 - **직접 리터럴 쓰기**(`[AttributeKey<<T>> "name"] = value`)는 공개
`AttributeKey(name)`을 그대로 씀, 정상 스캔으로 바로 `AttributeKey(name)`을 그대로 씀 — 한 Modifier 안에 같은 해시 키가
`AttributeKeyHandler`에 도달(인덱스 1) — 한 Modifier 안에 같은 해시 중복될 수 없어 이 경로 자체의 소유자는 항상 유일하고, 그룹이 이미 그
키가 중복될 수 없어 이 경로 자체의 claimant는 항상 유일. 그룹이 이름을 잡고 있으면 claim이 즉시 error.
이미 그 이름의 인덱스 1을 점유 중이면, 이 직접 쓰기의 - **`Tag`와의 대조** — `Tag`는 같은 이름을 여러 위치가 공유하는 게
`Dispatch.process(inst,key,value,1)`가 그 자리에서 곧바로 점유 error — **의도된 동작**(웹 `className` 합집합)이라 참조 카운트로 가고, Attribute는
그룹↔직접 쓰기 충돌도 같은 점유 체크 하나로 잡힘. 값이 하나뿐이라 겹침이 곧 충돌이라 claim으로 감. 두 정책의 차이는
- **그룹**은 아래 "메커니즘" 절에서 이름마다 `Dispatch.process`만으로 자원의 성질에서 나옴(`base/tag-plan.md`).
위임하고(철거는 반환 클로저가 전담) — 전용 키 객체도, 소유권 레지스트리도
필요 없음(항상 공개 `AttributeKey(name)`, 항상 인덱스 1). **`process`
위임 직전에 `retractFrom`을 부르면 안 된다는 게 이 절이 성립하는
전제** — 그러면 점유 여부와 무관하게 인덱스 1이 비워져 아래 점유
체크가 무력화됨(2026-08-13 감사에서 실제 그렇게 적혀 있던 걸 정정,
"메커니즘" 절 참고).
- **패키지 경계**: `AttributeKey`는 이미 quad-roblox 소속(Tag와 달리
base/roblox로 안 쪼갬, 아래 "패키지 배치" 절) — base쪽
`Attribute(...)` 값 객체 자신은 이 메커니즘을 전혀 모름.
## 그룹 `Attribute(...)` — 여러 Store를 한 번에 attribute로 (2026-08-11 아홉 번째 세션 신설) ## 그룹 `Attribute(...)` — 여러 Store를 한 번에 attribute로 (2026-08-11 아홉 번째 세션 신설)
@ -254,20 +287,18 @@ Frame { Attribute(styleStore), Attribute(stateStore) } -- 여러 개 나란히
슬롯을 그대로 가져와 자기 자신의 key→Source 맵에 넣는 것 — 아래 "레이어드 슬롯을 그대로 가져와 자기 자신의 key→Source 맵에 넣는 것 — 아래 "레이어드
Store 기각과 안 부딪히나" 참고. Store 기각과 안 부딪히나" 참고.
### 메커니즘 — 항상 인덱스 1부터 기존 단일 키 경로에 직접 위임 ### 메커니즘 — 그룹 전용 키로 단일 키 경로에 위임 (2026-08-13 열네 번째 세션 확정)
**[2026-08-11 아홉 번째 세션 후속, 개정]** 최초안은 "자기 완결형 Handler, **[2026-08-11 아홉 번째 세션 후속, 개정]** 최초안은 "자기 완결형 Handler,
Dispatch 재진입 없이 직접 `SetAttribute`+수동 per-field StoreBind 구독" Dispatch 재진입 없이 직접 `SetAttribute`+수동 per-field StoreBind 구독"
이었으나, 위 "동등성" 절의 이름별 weak 캐시가 확정되며 그 회피 이유 이었으나, 위 "동등성" 절의 이름별 캐시가 확정되며 그 회피 이유 자체가
자체가 없어짐 — 그래서 그룹 Handler는 **자기만의 SetAttribute/구독 로직을 없어짐 — 그래서 그룹 Handler는 **자기만의 set/구독 로직을 새로 만들지
새로 만들지 않고, 각 필드를 기존 단일 키 `AttributeKey` 경로에 그대로 않고, 각 필드를 기존 단일 키 `AttributeKey` 경로에 그대로 재귀 위임**한다
재귀 위임** — `None`/store-bind 전부 이미 확정된 단일 키 메커니즘을 (`None`/store-bind 전부 이미 확정된 단일 키 메커니즘을 100% 재사용, 중복
100% 재사용, 중복 구현 없음. **[전면 재정정, 2026-08-13 다섯 번째 세션] 구현 없음). **위임에 쓰는 키만 세 번 바뀌었고 지금은 그룹 전용 키**
`rawNew(name)` 그룹 전용 키 → `AttributeGroupKeyHandler` 체크포인트로 (`rawNew(name)` 전용 키 → `AttributeGroupKeyHandler` 체크포인트 → 공개
두 번 거쳐온 위임 메커니즘이, `Dispatch`의 인덱스 기반 재설계로 다시 `AttributeKey(name)`+점유 체크 → **비공개 `GetKey`(그룹 값 객체별·이름별
한번 단순화됨 — 이제 그냥 공개 `AttributeKey(name)`으로 인덱스 1에 메모이즈) + 이름 claim**) — 경위와 근거는 위 "이름 소유권" 절.
직접 위임**(경위는 위 "이름 소유권" 절, 원문은 `archive/
checkpoint-handler-pattern-reversed.md`):
**그룹의 `process`** — 다른 모든 핸들러와 똑같은 4-인자 계약 **그룹의 `process`** — 다른 모든 핸들러와 똑같은 4-인자 계약
`process(inst, k, v, index)`를 따름(`k`는 이 그룹 값이 놓인 array-part `process(inst, k, v, index)`를 따름(`k`는 이 그룹 값이 놓인 array-part
@ -275,68 +306,60 @@ checkpoint-handler-pattern-reversed.md`):
다른 것이라 헷갈리지 말 것**. 2026-08-13 감사 전까지 이 자리 시그니처가 다른 것이라 헷갈리지 말 것**. 2026-08-13 감사 전까지 이 자리 시그니처가
`process(inst, index, v)` 3-인자로 적혀 있었고 배열 위치를 하필 `index` `process(inst, index, v)` 3-인자로 적혀 있었고 배열 위치를 하필 `index`
부르고 있어서, 코퍼스에서 유일하게 계약과 안 맞는 핸들러였음). 반환하는 부르고 있어서, 코퍼스에서 유일하게 계약과 안 맞는 핸들러였음). 반환하는
클로저가 "이 호출이 등록한 이름 집합"을 직접 캡처 — **별도 `Relate` 클로저가 "이 호출이 등록한 키 목록"을 직접 캡처:
불필요**(2026-08-13 다섯 번째 세션, 클로저가 매 호출마다 자기 자신의
이름 집합을 새로 만들어 캡처하므로 사이클을 가로질러 저장해둘 이유가
없어짐):
**[정정, 2026-08-13 감사] `process``retractFrom`을 부르지 않는다 —
철거는 전적으로 반환 클로저 몫.** 최초 작성본은 `process` 안에서 이름마다
`Dispatch.retractFrom(inst, key, 1, source)`를 먼저 부른 뒤 `process`
불렀는데, 이러면 **인덱스 1을 누가 점유했든 무조건 비워버리므로 뒤따르는
`Dispatch.process`가 점유 error를 낼 수가 없음** — 위 "이름 소유권" 절이
이 재설계의 핵심 근거로 내세운 "점유 체크가 소유권 충돌 감지를 그대로
대신함"이 그룹↔그룹 사이에서 전혀 작동하지 않았음(그룹 B가 그룹 A의
바인딩을 조용히 파괴하고 이김 — 이 절이 없앴다고 선언한 바로 그
last-write-wins). 클로저가 자기가 등록한 이름 전부를 책임지고 걷어내면
`process`는 순수하게 `Dispatch.process`만 부르면 되고, 점유 체크가 다시
살아남:
```lua ```lua
function AttributeGroupHandler.process(inst, k, v, index) function AttributeGroupHandler.process(inst, k, v, index)
local names = {} local keys = {}
for name, source in pairs(v:NameMap()) do -- isHandlable이 이미 isAttribute(v)를 보장 for name, source in pairs(v:NameMap()) do -- isHandlable이 이미 isAttribute(v)를 보장
-- 공개 캐시 키 그대로(그룹 전용 키 불필요), 항상 인덱스 1부터 위임 — -- 그룹 전용 키(비공개) — 공개 AttributeKey(name)이 아님.
-- 이미 다른 그룹/직접 쓰기가 그 이름을 점유 중이면 여기서 즉시 점유 error -- 같은 그룹 값 객체 + 같은 이름이면 항상 같은 키 객체가 나와야 함.
Dispatch.process(inst, AttributeKey(name), source, 1) local key = groupKey(v, name)
names[name] = true Dispatch.process(inst, key, source, 1) -- 다른 키로 위임이므로 항상 인덱스 1
keys[key] = true
end end
return function() return function()
-- 이 그룹이 등록했던 이름 전부를 철거 — 생존/소멸 구분 없이 균일. -- 이 그룹이 등록했던 것 전부를 철거 — 생존/소멸 구분 없이 균일.
-- hintValue를 안 봄: 생존 이름도 일단 철거하고 다음 process가 다시 -- 인자(새 값)를 안 봄: 생존 이름도 일단 철거하고 다음 process가 다시
-- 등록하는 순서라(StoreBind가 retractFrom → process 순서를 보장), -- 등록하는 순서라(Dispatch가 retractor → process 순서를 보장),
-- "다음 값에 이 이름이 있나"를 미리 알 필요가 없음. -- "다음 값에 이 이름이 있나"를 미리 알 필요가 없음.
for name in pairs(names) do for key in pairs(keys) do
Dispatch.retractFrom(inst, AttributeKey(name), 1, nil) -- SetAttribute는 안 일어남(아래 원칙) Dispatch.retractFrom(inst, key, 1)
end end
end end
end end
``` ```
- **`groupKey(v, name)`는 그룹 값 객체별·이름별 메모이즈** — 같은 그룹
값이 재프로세스될 때 같은 키가 나와야 claim이 자기 자신과 안 부딪힘.
구현은 `Relate<그룹 값 → {[name] = key}>` 하나면 충분하고, **공개
API로 노출하지 않음**(위 "이름 소유권" 절). 그룹 값 객체 자체가 바뀌면
(`State<Attribute>`가 새 객체를 emit) 새 키가 나오는데, 그때는 옛
클로저가 먼저 전부 철거하므로 claim 충돌이 없음.
- **생존 이름도 매 사이클 철거→재등록됨(의도된 트레이드오프)** — 비용은 - **생존 이름도 매 사이클 철거→재등록됨(의도된 트레이드오프)** — 비용은
그 이름의 `StoreBind` 구독 해제+재구독, 그리고 재구독의 "등록 즉시 1회 그 이름의 `StoreBind` 구독 해제+재구독, 그리고 재구독의 "등록 즉시 1회
실행"이 같은 값으로 `SetAttribute`를 한 번 더 쏘는 것뿐. 이 문서가 이미 실행"이 같은 값으로 `setAttribute`를 한 번 더 쏘는 것뿐. 이 문서가 이미
"값 비교(`:Get()`으로 old/new 비교)는 안 함"을 확정해뒀으므로(아래 항목) "값 비교(`:Get()`으로 old/new 비교)는 안 함"을 확정해뒀으므로(아래 항목)
결이 같고, `SetAttribute`는 같은 값 재기록이 관측상 무해함. **`Tag` 결이 같고, `setAttribute`는 같은 값 재기록이 관측상 무해함. **체인이
`hintValue == v` 조기 반환을 여기에 넣으면 안 됨** — 클로저가 아무것도 그룹 전용이 된 지금은 이론상 "생존 이름은 그냥 다시 `Dispatch.process`
안 걷어낸 상태로 다음 `process`가 같은 이름에 다시 인덱스 1을 잡으려 불러 하강 diff에 맡기는" 최적화도 가능하지만**(다른 소유자를 건드릴
들어 자기 자신에게 점유 error를 냄. 위험이 없어졌으므로), 그러려면 옛 이름 집합을 `(inst,위치)`별로 또
들고 있어야 해서 부품이 늘어남 — **기본은 균일 철거 유지**, 최적화는
실제로 비용이 문제될 때 재검토.
- **그룹이 이름을 아예 놓는 경우도 같은 코드로 자연히 처리됨** — 클로저가 - **그룹이 이름을 아예 놓는 경우도 같은 코드로 자연히 처리됨** — 클로저가
`names` 전부를 걷어내고, 새 `process`가 새 이름 집합만 등록하므로 사라진 자기 키 전부를 걷어내고, 새 `process`가 새 이름 집합만 등록하므로 사라진
이름은 그냥 재등록이 안 될 뿐. 별도 diff 분기가 없어짐(이전 버전이 이름은 그냥 재등록이 안 될 뿐. 별도 diff 분기가 없음.
`newNames`를 계산하던 로직 자체가 불필요).
- **값 비교(`:Get()`으로 old/new 비교)는 안 함** — State - **값 비교(`:Get()`으로 old/new 비교)는 안 함** — State
계약("값은 항상 선언된 Compute 재실행 결과, 캐시 비교 금지", 계약("값은 항상 선언된 Compute 재실행 결과, 캐시 비교 금지",
`store-semantics.md` "하드 경계" 절)과 어긋나고, `source` `store-semantics.md` "하드 경계" 절)과 어긋나고, `source`
`State`/`Source`면 `Dispatch/StoreBind`가 알아서 언랩+구독까지 다 `State`/`Source`면 `Dispatch/StoreBind`가 알아서 언랩+구독까지 다
해줌(그룹 Handler가 따로 구독 관리 안 함)이라 굳이 비교할 이유가 없음. 해줌(그룹 Handler가 따로 구독 관리 안 함)이라 굳이 비교할 이유가 없음.
- **[확정, 2026-08-12 세션 후속, 사용자 결정] `retract``SetAttribute`를 - **[확정, 2026-08-12 세션 후속, 사용자 결정] 클로저는 `setAttribute`를
절대 안 부름 — Attribute는 오직 명시적 `None`/`nil`로만 지워진다.** 절대 안 부름 — Attribute는 오직 명시적 `None`/`nil`로만 지워진다.**
그룹에서 이름이 조용히 빠지든(diff로 사라짐), 그룹 바인딩 자체가 그룹에서 이름이 조용히 빠지든, 그룹 바인딩 자체가 통째로 사라지든
통째로 사라지든(컴포넌트 언마운트 등, `v`가 더 이상 Attribute가 아님) (컴포넌트 언마운트 등) 프레임워크가 자동으로 `setAttribute(inst,name,nil)`
프레임워크가 자동으로 `SetAttribute(name,nil)`을 대신 불러주지 대신 불러주지 않음 — 값이 이전 것 그대로 남는 게 정상 동작. `Ref`
않음 — 값이 이전 것 그대로 남는 게 정상 동작. `Ref`가 Destroy와 Destroy와 무관하게 동작하는 것과 같은 철학("지울 거면 명시적으로 지우라",
무관하게 동작하는 것과 같은 철학("지울 거면 명시적으로 지우라",
`ref-plan.md`의 "`Ref`의 retract" 절)으로 통일. **이전 초안은 `ref-plan.md`의 "`Ref`의 retract" 절)으로 통일. **이전 초안은
"Tag와 동일하게 확실히 청소"였으나 뒤집힘** — 이유: (1) diff로 "Tag와 동일하게 확실히 청소"였으나 뒤집힘** — 이유: (1) diff로
조용히 빠지는 이름은 안 지워주면서 통째 소멸일 땐 지워주면, 두 경우가 조용히 빠지는 이름은 안 지워주면서 통째 소멸일 땐 지워주면, 두 경우가
@ -349,40 +372,31 @@ end
그룹과 비교해 사라진 이름을 `None`으로 명시적으로 채워 넣는 유틸)을 그룹과 비교해 사라진 이름을 `None`으로 명시적으로 채워 넣는 유틸)을
나중에 opt-in으로 추가하면 됨 — 그건 사용자가 고른 명시적 선택이라 나중에 opt-in으로 추가하면 됨 — 그건 사용자가 고른 명시적 선택이라
모호하지 않음, 지금은 범위 밖(백로그). 모호하지 않음, 지금은 범위 밖(백로그).
- **다만 *구독*은 반드시 끊음 — 값은 안 지워도 자원은 새면 - **다만 *구독*은 반드시 끊음 — 값은 안 지워도 자원은 새면 안 됨.**
안 됨.** 위 "값은 안 지운다" 원칙과 별개로, 그룹이 더 이상 관리하지 그룹이 더 이상 관리하지 않는 이름의 체인을 그대로 두면 그 키에 걸려있던
않는 이름의 `(inst,key)` 체인을 그대로 두면 그 키에 걸려있던 `StoreBind` 구독이 인스턴스가 살아있는 동안 영원히 남아 원본 `Source`
`StoreBind` 구독이 인스턴스가 살아있는 동안 영원히 남아 원본 바뀔 때마다 계속 `setAttribute`를 쏘는 실제 리소스 누수가 됨(이건
`Source`가 바뀔 때마다 계속 `SetAttribute`를 쏘는 실제 리소스 누수가 "마지막 값이 남는다"는 것과 다른 문제 — 안 죽는 구독 자체가 문제).
됨(이건 "마지막 값이 남는다"는 것과 다른 문제 — 안 죽는 구독 자체가 그래서 반환하는 클로저는 자기가 등록했던 키 전부에 대해
문제). 그래서 반환하는 클로저는 **자기가 등록했던 이름 전부**에 대해 `Dispatch.retractFrom(inst, key, 1)`을 부름 — **`Dispatch.process`
`Dispatch.retractFrom(inst, AttributeKey(name), 1, nil)`을 부름 절대 안 부르므로**(이 클로저 안에서 새 등록을 트리거하는 건 체인 추적을
(**[정정, 2026-08-13 감사]** 원래는 "사라진 이름에 한해"였으나, 그 꼬는 UB, `dispatch-core-plan.md` 일반 규칙) 그 키 아래가 전부 자기
선별을 하려면 `process` 쪽이 생존 이름을 `retractFrom`으로 강제 자신의 클로저만 타고 끝나 `setAttribute`는 여기서도 절대 안 일어남 —
회수해야 해서 점유 체크가 무력화됐음 — 위 "메커니즘" 절 참고. 전부 위 "명시적 None으로만 지운다" 원칙과 안 부딪힘.
걷어내고 새 `process`가 다시 등록하는 쪽이 점유 체크를 살리면서
코드도 더 단순함) —
**`Dispatch.process`는 절대 안 부르므로**(이 클로저 안에서 새 등록을
트리거하는 건 체인 추적을 꼬는 UB, `bind-system-plan.md` 일반 규칙)
그 이름 아래가 전부 자기 자신의 클로저만 타고 끝나 `SetAttribute`
여기서도 절대 안 일어남 — 위 "명시적 None으로만 지운다" 원칙과 안
부딪힘.
- **필드 하나만 바뀌는 흔한 경우**(`storeA.foo:Set(v)`, 그룹 자체는 - **필드 하나만 바뀌는 흔한 경우**(`storeA.foo:Set(v)`, 그룹 자체는
안 바뀜)는 위 그룹 재처리를 아예 거치지 않음 — 마운트 시 이미 걸린 안 바뀜)는 위 그룹 재처리를 아예 거치지 않음 — 마운트 시 이미 걸린
단일 키 `AttributeKeyHandler`의 store-bind 구독이 바로 단일 키 `AttributeKeyHandler`의 store-bind 구독이 바로
`SetAttribute("foo", v)`를 호출(그룹 재진입 없이 그 경로 스스로). `setAttribute(inst,"foo",v)`를 호출(그룹 재진입 없이 그 경로 스스로).
**`AttributeChanged`/`GetAttributeChangedSignal` 남발 걱정 없음** — **`AttributeChanged`/`GetAttributeChangedSignal` 남발 걱정 없음** —
키 집합이 안 바뀌는 한 diff 로직 자체가 안 돎. 키 집합이 안 바뀌는 한 그룹 로직 자체가 안 돎.
**그룹 Handler에 남는 자기 로직은 사실상 "이름 집합을 순회하며 위임하고, **그룹 Handler에 남는 자기 로직은 사실상 "이름 집합을 순회하며 자기 전용
클로저가 같은 집합을 걷어내는 것"뿐**(**[정정, 2026-08-13 감사]** 예전엔 키로 위임하고, 클로저가 같은 키들을 걷어내는 것"뿐** — 실제
"이름 집합 diff"라고 적었으나, 위 재정정으로 diff 자체가 없어짐 — `setAttribute` 호출/`None` 처리/store-bind 구독/이름 claim은 전부 단일 키
`process`는 새 집합을 전부 등록, 클로저는 옛 집합을 전부 철거) — 실제 경로가 담당. `TagHandler``addTag`/`removeTag`를 직접 호출하는 것과
`SetAttribute` 호출/`None` 처리/store-bind 구독은 전부 기존 단일 키 달리 여기서는 그 실행 자체를 위임한다는 점이 다름(Attribute만 "이미
경로가 그대로 담당. `TagHandler``CollectionService` 호출을 직접 하는 완성된 재사용 가능한 단일 키 경로"가 있어서 가능한 차이 — Tag는 애초에
것과 달리, 여기서는 그 실행 자체를 위임한다는 점이 다름(Attribute만 이름 하나짜리 단일 키 대응물이 없음).
"이미 완성된 재사용 가능한 단일 키 경로"가 있어서 가능한 차이 — Tag는
애초에 이름 하나짜리 단일 키 대응물이 없음).
### `Attribute.Merged`가 레이어드 Store 기각과 안 부딪히나 — 점검, 안 부딪힘 ### `Attribute.Merged`가 레이어드 Store 기각과 안 부딪히나 — 점검, 안 부딪힘
@ -402,30 +416,60 @@ end
기각 사유(범용 컨테이너의 암묵적 런타임 폴백)가 이 케이스엔 적용 안 됨 — 기각 사유(범용 컨테이너의 암묵적 런타임 폴백)가 이 케이스엔 적용 안 됨 —
새 primitive 추가에 문제없음. 새 primitive 추가에 문제없음.
### 패키지 배치 — Tag와 동일 원칙 ### 패키지 배치 — 값 타입도 알고리즘도 quad-base, 주입되는 건 `setAttribute` 하나 (2026-08-13 열네 번째 세션 전면 재배치)
값 타입+API(`Attribute(...)`/`Merged`)는 quad-base(엔진 무관, 순수 데이터+ **[전면 재배치, 2026-08-13 열네 번째 세션, 사용자 판단]** 예전엔 값
연산). `SetAttribute` 실제 호출 글루만 quad-roblox. 타입/API만 quad-base이고 **단일 키 `AttributeKey`와 두 Handler는 통째로
quad-roblox** 소속이었음 — 그런데 실제로 엔진에 종속된 건 마지막 한 줄
(`inst:SetAttribute`)뿐이고, 이름 claim·그룹 위임·`None` 처리·이름별 weak
캐시는 전부 순수 부기임. 웹에도 대응물이 있으므로(`data-*`) 그 배치대로면
**같은 소유권 알고리즘을 백엔드마다 재구현**하게 됨 — `architecture.md`
"엔진마다 큰 구현을 중복하지 않기 위해 디스패치 엔진을 base가 인터페이스로
소유한다"는 원칙에 정면으로 어긋남.
## 패키지 배치 (단일 키 `AttributeKey`) **확정된 배치**:
UICorner 숏핸드/Tween/Tag와 같은 판단 재사용 — `quad-roblox` 코어에 직접 | 무엇 | 어디 |
포함, 별도 opt-out 패키지로 안 쪼갬. |---|---|
| 그룹 값 타입+API(`Attribute(...)`/`Merged`/`:NameMap`) | quad-base |
| 단일 키 `AttributeKey<<T>>(name)` + 이름별 weak 캐시 | quad-base |
| 스칼라 편의 패밀리(`StringAttribute`/`NumberAttribute`/`BooleanAttribute`) | quad-base |
| `AttributeKeyHandler`(이름 claim 포함) / `AttributeGroupHandler`(전용 키 위임) | quad-base, `HANDLER_PRIORITY_FALLBACK`으로 등록 |
| 엔진 고유 타입 패밀리(`Color3Attribute`/`UDim2Attribute`/`InstanceAttribute`류) | 백엔드(quad-roblox의 `D`/`DI` 층) |
| **`setAttribute(inst, name, v)`** — `v == nil`이면 그 이름을 지움 | 백엔드가 주입 |
- **왜 타입 패밀리만 갈리는가**: Roblox attribute가 받는 타입 집합
(`Color3`/`UDim2`/`CFrame`/`Instance` 등)은 엔진 고유 어휘라 base가 알
수 없음. 반대로 string/number/boolean은 어느 백엔드에나 있으므로 base에
둔다. "이 값이 이 백엔드에서 표현 가능한가"라는 **검증도 base가 아니라
주입된 `setAttribute`의 몫** — base는 값을 그대로 흘려보냄.
- **백엔드가 통째로 다르게 하고 싶으면** 평범한 우선순위로 자기 핸들러를
등록하면 됨(base 것은 최하위 밴드라 자동으로 짐) — 상세는
`base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진
op" 절. `Tag`도 정확히 같은 구조(`base/tag-plan.md`).
- 단일 키를 별도 opt-out 패키지로 쪼개지 않는다는 기존 판단은 그대로
(UICorner 숏핸드/Tween/Tag와 같은 결) — 다만 "어느 패키지의 코어인가"가
quad-roblox에서 quad-base로 바뀐 것.
## 열린 질문 (`.claude/question.md`에도 취합) ## 열린 질문 (`.claude/question.md`에도 취합)
- **[2026-08-13 3차 감사에서 보강] `hintValue`/`retractFrom` 재-dispatch - **[해소, 2026-08-13 열네 번째 세션] 이름 소유권(`question.md` 0-Z)과
메커니즘은 `question.md` **0-Z**(이 문서 자신의 이름 소유권 결정) 하강 diff 재디스패치(0-A)는 확정·반영 완료** — 위 "이름 소유권"/
해소 대기 중** — 이 문서 최상단 배너와 "이름 소유권" 절에 이미 "메커니즘" 절이 정본, 뒤집힌 옛 모델은
명시돼 있지만, 이 목록에는 빠져 있어서 여기만 훑는 독자가 놓치기 `archive/dispatch-hintvalue-model-reversed.md`.
쉬움. `research/dispatch-redispatch-diff-plan.md` 먼저 읽을 것. - **[열림, 사소함, 2026-08-13 열네 번째 세션 신설] `Attribute.Merged`에서
두 Store가 같은 이름을 가지면 지금은 조용히 하나가 이김** —
`:NameMap()` 평탄화가 dispatch 이전 단계라 위 이름 claim이 못 잡는
자리. 이름 겹침을 error로 잡는 게 이 문서의 다른 결정들과 결이 같고
구현도 싸지만(합성 시점 1회 체크), "Merged는 뒤가 이긴다"를 의도된
override로 볼 여지도 있어서 사용자 확인 대기 — `question.md` 3번.
- **이름은 잠정 확정, 최종 확정은 대기열**: 겹침 방지를 위해 그룹 값은 - **이름은 잠정 확정, 최종 확정은 대기열**: 겹침 방지를 위해 그룹 값은
`Attribute`, 단일 키는 `AttributeKey<<T>>`로 코드/문서 전체 통일해서 `Attribute`, 단일 키는 `AttributeKey<<T>>`로 코드/문서 전체 통일해서
당장의 해석 모호성은 없앴음 — 그래도 최종 이름은 다른 가칭들(`State`/ 당장의 해석 모호성은 없앴음 — 그래도 최종 이름은 다른 가칭들(`DI`→`D`/
`DI`→`D`/`Slot`/`canExecute`/`Brand`)과 함께 `.claude/question.md` 용어정리 `Slot`/`canExecute`/`Brand`)과 함께 `.claude/question.md` 용어정리
대기열에 있음, 나중에 한꺼번에 재검토. 대기열에 있음, 나중에 한꺼번에 재검토.
- **[백로그, 2026-08-12 세션 후속]** 그룹이 이름을 조용히 놓아도 - **[백로그, 2026-08-12 세션 후속]** 그룹이 이름을 조용히 놓아도
`SetAttribute(name,nil)`을 자동으로 안 해준다는 위 "그룹 `Attribute(...)`" `setAttribute(inst,name,nil)`을 자동으로 안 해준다는 위 "그룹 `Attribute(...)`"
절의 결정 — 그래도 명시적 자동 unset이 갖고 싶으면 `Animate`와 같은 절의 결정 — 그래도 명시적 자동 unset이 갖고 싶으면 `Animate`와 같은
모양의 `:Apply` opt-in 유틸(이전 이름 집합과 비교해 사라진 이름을 모양의 `:Apply` opt-in 유틸(이전 이름 집합과 비교해 사라진 이름을
`None`으로 채워주는 콤비네이터)을 나중에 추가할 수 있음, 착수 안 함 — `None`으로 채워주는 콤비네이터)을 나중에 추가할 수 있음, 착수 안 함 —

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -242,7 +242,7 @@ end
직접 읽기로 확정했으나(위 구현), 실제 Luau 필드 접근 비용/weak table 조회 직접 읽기로 확정했으나(위 구현), 실제 Luau 필드 접근 비용/weak table 조회
비용 비교는 여전히 quad-roblox 구현 단계에서 실측 확인 대상. 비용 비교는 여전히 quad-roblox 구현 단계에서 실측 확인 대상.
이건 `base/bind-system-plan.md`의 "핸들러 내부 상태 저장" 유틸(`Relate` 이건 `base/dispatch-core-plan.md`의 "핸들러 내부 상태 저장" 유틸(`Relate`
직접 사용)과 짝을 이루는 별도 유틸 — 하나는 "상태를 어디에 저장할지" 직접 사용)과 짝을 이루는 별도 유틸 — 하나는 "상태를 어디에 저장할지"
(`Relate:SetStrong`/`:SetWeak`), 다른 하나는 "언제까지 실행되어도 되는지" (`Relate:SetStrong`/`:SetWeak`), 다른 하나는 "언제까지 실행되어도 되는지"
(`bindLifetime` + `canExecute`)를 다룸. 후자는 내부적으로 전자가 제공하는 (`bindLifetime` + `canExecute`)를 다룸. 후자는 내부적으로 전자가 제공하는
@ -325,6 +325,6 @@ Roblox 엔진 자체가 Destroy 시 Tag/Attribute/실행 중인 Tween을 전부
자체는 그대로 유효하고(이 절의 결론은 안 바뀜), 다만 코드에서 자체는 그대로 유효하고(이 절의 결론은 안 바뀜), 다만 코드에서
`handler.retract(...)`를 찾으면 안 됨 — `local retractor = `handler.retract(...)`를 찾으면 안 됨 — `local retractor =
handler.process(inst,k,v,index)` 형태로 받아서 `Dispatch``chains` handler.process(inst,k,v,index)` 형태로 받아서 `Dispatch``chains`
보관했다가 부름(`base/bind-system-plan.md` "핸들러 계약"/"Dispatch 체인" 보관했다가 부름(`base/dispatch-core-plan.md` "핸들러 계약"/"Dispatch 체인"
절). 이 문서가 계속 쓰는 "retract 시점"/"retract가 불린다"는 표현은 절). 이 문서가 계속 쓰는 "retract 시점"/"retract가 불린다"는 표현은
전부 그 클로저가 호출되는 시점을 가리킴. 전부 그 클로저가 호출되는 시점을 가리킴.

View file

@ -50,7 +50,7 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류
수행하는 "처리된 값을 다시 `Dispatch.process(inst,k,realv)`로 넘기는" 재실행 수행하는 "처리된 값을 다시 `Dispatch.process(inst,k,realv)`로 넘기는" 재실행
로직 자체도 base가 한 번만 구현**해야 함(모든 백엔드/핸들러가 각자 로직 자체도 base가 한 번만 구현**해야 함(모든 백엔드/핸들러가 각자
재구현하면 안 됨). 근거: "모든 곳에서 다시 구현하는 건 나쁘니까." → 재구현하면 안 됨). 근거: "모든 곳에서 다시 구현하는 건 나쁘니까." →
`base/bind-system-plan.md`의 "확정된 디스패치 모델"/`Dispatch` 네이밍 절이 `base/dispatch-core-plan.md`의 "확정된 디스패치 모델"/`Dispatch` 네이밍 절이
바로 이 base 제공 로직. 바로 이 base 제공 로직.
부수적으로 확인된 것: 부수적으로 확인된 것:
@ -104,9 +104,9 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류
인터페이스"가 곧 Handler 계약: `isHandlable(inst,key,value)`/ 인터페이스"가 곧 Handler 계약: `isHandlable(inst,key,value)`/
`priority`/`process(inst,key,value,index)` **3종**(**[정정, 2026-08-13 `priority`/`process(inst,key,value,index)` **3종**(**[정정, 2026-08-13
다섯 번째 세션]** 원래 별도 `retract(inst,key,value)` 필드가 있던 4종 다섯 번째 세션]** 원래 별도 `retract(inst,key,value)` 필드가 있던 4종
계약이었으나, `process`가 자기 retract 클로저 `(hintValue) -> ()`를 계약이었으나, `process`가 자기 retract 클로저 `(nextValue: any?) -> ()`를
반환하는 1-메소드로 합쳐짐), 정리할 게 없어도 `function() end` 반환하는 1-메소드로 합쳐짐), 정리할 게 없어도 `function() end`
반환 생략 불가까지 확정. `base/bind-system-plan.md` "핸들러 계약" 절. 반환 생략 불가까지 확정. `base/dispatch-core-plan.md` "핸들러 계약" 절.
- ~~**네이밍 미정(2026-08-04 보강)**: "프로바이더"라고 불러온 개념을 정확히 - ~~**네이밍 미정(2026-08-04 보강)**: "프로바이더"라고 불러온 개념을 정확히
뭐라고 부를지("provider" vs "processor" vs 그냥 "plug") 아직 안 정함~~ 뭐라고 부를지("provider" vs "processor" vs 그냥 "plug") 아직 안 정함~~
**[해소됨]** — **`Handler`로 확정**, 위 항목이 가리키는 계약의 정식 이름. **[해소됨]** — **`Handler`로 확정**, 위 항목이 가리키는 계약의 정식 이름.
@ -129,7 +129,14 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류
처리" 절의 일반 "매치 실패 시 즉시 error" 규칙 하나로 자연히 커버됨. 처리" 절의 일반 "매치 실패 시 즉시 error" 규칙 하나로 자연히 커버됨.
- base 유틸(per-instance 상태 저장소, 생명 바인드 유틸)이 인터페이스만 두고 - base 유틸(per-instance 상태 저장소, 생명 바인드 유틸)이 인터페이스만 두고
실제 구현은 백엔드 팩토리(`RobloxFactory(BaseModule)`류)가 뮤테이션으로 실제 구현은 백엔드 팩토리(`RobloxFactory(BaseModule)`류)가 뮤테이션으로
주입한다는 패턴이 확정됨 — 상세는 `base/bind-system-plan.md`의 "base 주입한다는 패턴이 확정됨. **[2026-08-13 열네 번째 세션] 주입 대상 목록에
엔진 op 3개가 추가됨** — `addTag(inst,{string})`/`removeTag(inst,{string})`/
`setAttribute(inst,name,v)`(`v==nil`이면 삭제). `Tag`/`Attribute`의 부기
알고리즘이 통째로 quad-base로 옮겨오면서, 엔진에 실제로 손대는 마지막
한 줄만 이 경로로 주입받게 됨(`base/dispatch-core-plan.md` "base가
소유하는 핸들러와 주입되는 엔진 op" 절). 미주입 백엔드에서는 base
스텁이 명확한 에러를 내고, 백엔드가 통째로 다르게 처리하고 싶으면
`HANDLER_PRIORITY_FALLBACK`보다 높은 우선순위로 자기 핸들러를 등록하면 됨 — 상세는 `base/bind-system-plan.md`의 "base
유틸은 인터페이스, 실제 구현은 백엔드 팩토리가 주입" 절 참고. **중복 호출 유틸은 인터페이스, 실제 구현은 백엔드 팩토리가 주입" 절 참고. **중복 호출
가드/`New()`와의 관계는 2026-08-04 3차 라운드에서 확정**: 같은 팩토리로 가드/`New()`와의 관계는 2026-08-04 3차 라운드에서 확정**: 같은 팩토리로
재호출하면 무시(no-op), 다른 팩토리로 재호출하면 에러(유일 슬롯 충돌 — 재호출하면 무시(no-op), 다른 팩토리로 재호출하면 에러(유일 슬롯 충돌 —
@ -137,4 +144,4 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류
생기면 인스턴스별 테이블이 분리되므로 이 가드도 자연히 인스턴스별로 생기면 인스턴스별 테이블이 분리되므로 이 가드도 자연히 인스턴스별로
스코핑됨, 별도 재설계 불필요. **이 결론이 Dispatch의 handler 레지스트리에도 스코핑됨, 별도 재설계 불필요. **이 결론이 Dispatch의 handler 레지스트리에도
그대로 적용된다는 게 2026-08-08 두 번째 세션에서 재확인/일반화됨** — 그대로 적용된다는 게 2026-08-08 두 번째 세션에서 재확인/일반화됨** —
`base/bind-system-plan.md` "Dispatch는 프리미티브가 아니다" 절. `base/dispatch-core-plan.md` "Dispatch는 프리미티브가 아니다" 절.

View file

@ -37,9 +37,14 @@
"제네릭 + 자주 쓰는 것만 정적 지름길" 절충과 겉보기엔 비슷해 보이지만 "제네릭 + 자주 쓰는 것만 정적 지름길" 절충과 겉보기엔 비슷해 보이지만
규모가 다른 문제라 기각. 규모가 다른 문제라 기각.
- **패키지 경계: 전부 quad-roblox**`Handlers/OnChange.luau``OnChange(name)` - **패키지 경계: 전부 quad-roblox**`Handlers/OnChange.luau``OnChange(name)`
키 팩토리와 Handler를 같이 둠(단일 키 `AttributeKey.luau`와 같은 배치, 키 팩토리와 Handler를 같이 둠. **[정정, 2026-08-13 열네 번째 세션]**
base 쪽 값 타입 파일 없음 — 그룹 `Attribute.luau`는 값 타입 자체가 예전엔 "단일 키 `AttributeKey.luau`와 같은 배치"라고 적었으나 그
quad-base 소속이라 다름, `base/attribute-plan.md` 참고). `AttributeKey`는 같은 세션에 **quad-base로 옮겨갔음**(부기가 엔진 지식을
요구하지 않아서, `base/attribute-plan.md` "패키지 배치" 절) — `OnChange`
quad-roblox에 남는 이유는 그것과 달리 **`GetPropertyChangedSignal`
자체가 로직**이라 "한 줄 op 주입"으로 줄어들지 않기 때문
(`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진
op" 절의 분할 기준).
`GetPropertyChangedSignal` 자체가 Roblox 엔진 API라 base에 `GetPropertyChangedSignal` 자체가 Roblox 엔진 API라 base에
둘 이유가 없음 — Tag처럼 백엔드 무관한 값/API 레이어가 따로 있는 경우와 둘 이유가 없음 — Tag처럼 백엔드 무관한 값/API 레이어가 따로 있는 경우와
다름. 다름.
@ -73,7 +78,7 @@
| | 소스 | 값 타입 | 패키지 경계 | | | 소스 | 값 타입 | 패키지 경계 |
|---|---|---|---| |---|---|---|---|
| 이벤트(`MouseButton1Click = fn`) | `inst[key]`가 이미 Signal | 콜백, 타입 미검증 | quad-roblox(`Handlers/Event.luau`) | | 이벤트(`MouseButton1Click = fn`) | `inst[key]`가 이미 Signal | 콜백, 타입 미검증 | quad-roblox(`Handlers/Event.luau`) |
| `AttributeKey(name)` | `SetAttribute`/`GetAttribute` | 값(제네릭 또는 정적 타입 패밀리로 타입 파라미터화) | quad-roblox(`Handlers/AttributeKey.luau`) | | `AttributeKey(name)` | 주입된 `setAttribute` op | 값(제네릭 또는 정적 타입 패밀리로 타입 파라미터화) | **quad-base**(키+Handler, 2026-08-13 열네 번째 세션 재배치) / 엔진 op만 백엔드 |
| `OnChange(name)` | `GetPropertyChangedSignal(name)` | 콜백, 타입 미검증(제네릭 없음) | quad-roblox(`Handlers/OnChange.luau`) | | `OnChange(name)` | `GetPropertyChangedSignal(name)` | 콜백, 타입 미검증(제네릭 없음) | quad-roblox(`Handlers/OnChange.luau`) |
`OnChange`가 Attribute처럼 제네릭화되지 않은 이유는 "콜백을 받는다"는 `OnChange`가 Attribute처럼 제네릭화되지 않은 이유는 "콜백을 받는다"는

View file

@ -12,8 +12,8 @@
**중요한 정정**: Ref는 Tween이 대상을 얻기 위해 필요한 게 아님(트윈을 **중요한 정정**: Ref는 Tween이 대상을 얻기 위해 필요한 게 아님(트윈을
실제로 처리하는 PropertyHandler도 `process(inst,k,v)`처럼 항상 대상 실제로 처리하는 PropertyHandler도 `process(inst,k,v)`처럼 항상 대상
Instance를 직접 받으므로 — 위 "확정된 디스패치 모델" 참고, `research/ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디스패치
tween-plan.md`도 이에 맞춰 갱신됨). Ref의 진짜 용도는 다름: 모델" 참고, `base/tween-plan.md`도 이에 맞춰 갱신됨). Ref의 진짜 용도는 다름:
- v1의 `Frame "id" {}` + `Store.GetObject(id)` 식 id 매핑은 폐기 확정 - v1의 `Frame "id" {}` + `Store.GetObject(id)` 식 id 매핑은 폐기 확정
(`base/architecture.md` 5번 항목) — "비현실적"이라는 게 이유. (`base/architecture.md` 5번 항목) — "비현실적"이라는 게 이유.
@ -210,16 +210,13 @@ tween-plan.md`도 이에 맞춰 갱신됨). Ref의 진짜 용도는 다름:
### `Ref`의 retract — `State<Ref>` 재바인드 시 이전 Ref에 `nil` (2026-08-12 여덟 번째 세션, `TagHandler`와 같은 메커니즘 재사용) ### `Ref`의 retract — `State<Ref>` 재바인드 시 이전 Ref에 `nil` (2026-08-12 여덟 번째 세션, `TagHandler`와 같은 메커니즘 재사용)
> **⚠️ [2026-08-13 감사 보강] 이 절의 "이전 `process`가 반환한 클로저가 > **✅ [2026-08-13 열네 번째 세션] 하강 diff 재디스패치 반영 완료.**
> `hintValue`로 먼저 불려 언바인딩하고, 그 다음 `process`가 바인딩"이라는 > "이전 클로저가 먼저 불려 언바인딩, 그 다음 `process`가 바인딩"이라는
> 서술은 곧 교체될 예정 — 아직 반영 안 됨. `research/dispatch-redispatch-diff-plan.md` > **두 단계 자체는 그대로**이고, 그걸 일으키는 주체만 바뀌었음(래핑
> 폐기 대상으로 지목한 바로 그 **선행 `retractFrom` 호출** 패턴이다 — 이 문서가 > 핸들러의 선행 `retractFrom``Dispatch.process`의 핸들러 선비교).
> `bind-system-plan.md`에서 분리(2026-08-13 아홉 번째 세션, 순수 이동)될 > 클로저가 받는 값의 타입도 이제 계약으로 보장됨(항상 `Ref`이거나
> 때 원문이 그대로 옮겨오면서 `bind-system-plan.md`/`tag-plan.md`/ > `nil`). 상세는 `base/dispatch-core-plan.md` "Dispatch 체인" 절, 옛
> `slot-plan.md`/`attribute-plan.md`가 이미 달고 있는 것과 같은 0-Z > 모델 원문은 `archive/dispatch-hintvalue-model-reversed.md`.
> 배너가 같이 안 옮겨졌음. `question.md` **0-Z**(Attribute 이름 소유권)
> 해소 시 "하강 diff" 모델대로 이 절도 같이 재작성할 것 — **여기 적힌
> 대로 구현하면 옛 모델로 짜게 됨.**
**배경**: `Ref`는 이미 "일반 프로퍼티/Modifier 필드/Store 값 어디든 자유롭게 **배경**: `Ref`는 이미 "일반 프로퍼티/Modifier 필드/Store 값 어디든 자유롭게
들어감"(아래 "동적 경로 가드" 절)이 확정돼 있어 — `State<Ref>`가 실제로 들어감"(아래 "동적 경로 가드" 절)이 확정돼 있어 — `State<Ref>`가 실제로
@ -229,19 +226,20 @@ tween-plan.md`도 이에 맞춰 갱신됨). Ref의 진짜 용도는 다름:
조용한 버그가 남음 — `PreRef` 재사용 버그(위 절)와 같은 클래스의 문제. 조용한 버그가 남음 — `PreRef` 재사용 버그(위 절)와 같은 클래스의 문제.
**메커니즘 — retractor가 매번 불린다는 전제 위에서 언바인딩 전담 **메커니즘 — retractor가 매번 불린다는 전제 위에서 언바인딩 전담
(2026-08-12 열한 번째 세션 정정, 2026-08-13 다섯 번째 세션에 클로저 (2026-08-12 열한 번째 세션 정정, 2026-08-13 다섯 번째/열네 번째 세션에
반환 계약으로 서술 갱신).** `Dispatch.retractFrom`은 store 값이 바뀔 서술 갱신).** 이전 `process`가 반환한 클로저는 store 값이 바뀔
때마다(핸들러 타입이 그대로여도) 무조건 불림 — 위 "확정된 디스패치 때마다(핸들러 타입이 그대로여도) 무조건 불림 — 같은 핸들러면
모델"/일반 retract 계약 절 참고. 그래서 `refA→refB` 전환도 이전 `Dispatch.process`가 그 자리 클로저에 새 값을 넘기고 곧바로 `process`
`process`가 반환한 클로저가 `hintValue=refB`로 먼저 불려 `refA` 다시 부르기 때문(`base/dispatch-core-plan.md` "Dispatch 체인" 절 (A)
언바인딩하고, 그 다음 `process(inst,k,refB,index)``refB`를 바인딩하는 분기). 그래서 `refA→refB` 전환은 이전 클로저가 `nextValue=refB`로 먼저
두 단계로 자연히 갈림 — `process`가 old-vs-new diff를 따로 계산할 불려 `refA`를 언바인딩하고, 그 다음 `process(inst,k,refB,index)`
`refB`를 바인딩하는 두 단계로 자연히 갈림 — `process`가 old-vs-new diff를 따로 계산할
필요가 없어짐(그 일을 클로저가 매번 정확히 대신 해줌). **`process` 쪽엔 필요가 없어짐(그 일을 클로저가 매번 정확히 대신 해줌). **`process` 쪽엔
여전히 `Relate`가 필요** — "spurious하게 같은 Ref가 재발행되면 재통지 여전히 `Relate`가 필요** — "spurious하게 같은 Ref가 재발행되면 재통지
skip"이라는 dedup은 `process`가 "이전에 뭐가 있었는지"를 알아야 하는데, skip"이라는 dedup은 `process`가 "이전에 뭐가 있었는지"를 알아야 하는데,
그건 인자로 안 들어오고(클로저`hintValue` 다음 값이지 이전 값이 그건 인자로 안 들어오고(클로저가 받는 건 다음 값이지 이전 값이
아님) 오직 여러 호출을 가로지르는 저장소로만 알 수 있음(위 "핸들러 아님) 오직 여러 호출을 가로지르는 저장소로만 알 수 있음
내부 상태 저장" 절이 이런 경우엔 `Relate`가 여전히 맞다고 한 그 사례): (`base/dispatch-core-plan.md` "핸들러 내부 상태 저장" 절이 이런 경우엔 `Relate`가 여전히 맞다고 한 그 사례):
```lua ```lua
local relate = Relate() -- Ref-leaf handler 전용, (inst,k)별 마지막으로 바인딩한 Ref 기억 — local relate = Relate() -- Ref-leaf handler 전용, (inst,k)별 마지막으로 바인딩한 Ref 기억 —
@ -255,14 +253,14 @@ function RefLeafHandler.process(inst, k, v, index)
v:Set(inst) v:Set(inst)
end end
relate:SetStrong(inst, k, v) relate:SetStrong(inst, k, v)
return function(hintValue) return function(nextValue)
-- hintValue는 nil일 수도, 대체하는 새 Ref 자체일 수도 있음 — v는 -- nextValue는 nil이거나 같은 핸들러가 곧 처리할 새 Ref(타입 보장됨) — v는
-- 이 process 호출이 만든 클로저가 직접 캡처(Relate 재조회 불필요) -- 이 process 호출이 만든 클로저가 직접 캡처(Relate 재조회 불필요)
if hintValue ~= v then if nextValue ~= v then
v:Set(nil) -- 매 :Set()마다 콜백 재통지되는 기존 Ref 규칙(위 "해소됨 — v:Set(nil) -- 매 :Set()마다 콜백 재통지되는 기존 Ref 규칙(위 "해소됨 —
-- 반복 재설정 가능" 항목)을 그대로 재사용, 새 알림 경로 아님 -- 반복 재설정 가능" 항목)을 그대로 재사용, 새 알림 경로 아님
-- [정정, 2026-08-13 감사] relate 정리는 반드시 이 분기 *안*에 있어야 -- [정정, 2026-08-13 감사] relate 정리는 반드시 이 분기 *안*에 있어야
-- 함 — 밖에 두면 spurious 재발행(hintValue == v)에서도 기록이 -- 함 — 밖에 두면 spurious 재발행(nextValue == v)에서도 기록이
-- 지워져, 곧바로 이어지는 process가 `old ~= v`를 항상 참으로 보고 -- 지워져, 곧바로 이어지는 process가 `old ~= v`를 항상 참으로 보고
-- `v:Set(inst)`를 재실행함(콜백 헛 재통지). 즉 아래 dedup 항목이 -- `v:Set(inst)`를 재실행함(콜백 헛 재통지). 즉 아래 dedup 항목이
-- 약속한 "spurious면 둘 다 스킵"이 성립을 안 했음. -- 약속한 "spurious면 둘 다 스킵"이 성립을 안 했음.
@ -273,7 +271,7 @@ end
``` ```
- **retractor가 언바인딩 전담, `process`는 바인딩 전담** — 겹치는 diff - **retractor가 언바인딩 전담, `process`는 바인딩 전담** — 겹치는 diff
로직이 없음. `hintValue == v`(같은 Ref 객체가 스스로 재발행된 로직이 없음. `nextValue == v`(같은 Ref 객체가 스스로 재발행된
spurious한 경우)만 둘 다 스킵해 콜백이 `nil`→`inst`로 헛되이 두 번 안 spurious한 경우)만 둘 다 스킵해 콜백이 `nil`→`inst`로 헛되이 두 번 안
불리게 함. 불리게 함.
- **children 배열 리터럴 `Ref`도 같은 코드 경로를 그대로 씀** — 그 경우 - **children 배열 리터럴 `Ref`도 같은 코드 경로를 그대로 씀** — 그 경우
@ -309,7 +307,8 @@ end
아홉 번째 세션에서 폐기됨, 위 "바인드 방법" 절 참고) 아홉 번째 세션에서 폐기됨, 위 "바인드 방법" 절 참고)
**children 배열에 놓는 Ref에 `{phase="created"|"mounted"}` 옵션으로 두 **children 배열에 놓는 Ref에 `{phase="created"|"mounted"}` 옵션으로 두
타이밍을 고르게 하던 것 자체를 없앤다.** 위 "확정된 디스패치 모델" 절에 타이밍을 고르게 하던 것 자체를 없앤다.** `base/dispatch-core-plan.md`
"확정된 디스패치 모델" 절에
새로 추가된 두 패스 보장(배열 파트는 index 순서대로, 그 다음 해시 파트) 새로 추가된 두 패스 보장(배열 파트는 index 순서대로, 그 다음 해시 파트)
덕분에, 같은 인스턴스 안에서 **일반 `Ref`를** 다른 children보다 앞/뒤 덕분에, 같은 인스턴스 안에서 **일반 `Ref`를** 다른 children보다 앞/뒤
어디에 놓느냐가 어디에 놓느냐가

View file

@ -94,7 +94,7 @@ relate:GetWeak(inst: any, key: any): any?
## 언제 `Relate`를 쓰고 언제 쓰면 안 되는가 — 체크리스트 (2026-08-13 여섯 번째 세션 신설) ## 언제 `Relate`를 쓰고 언제 쓰면 안 되는가 — 체크리스트 (2026-08-13 여섯 번째 세션 신설)
Handler 계약이 "`process`가 자기 retract 클로저를 반환"으로 바뀌면서 Handler 계약이 "`process`가 자기 retract 클로저를 반환"으로 바뀌면서
(`base/bind-system-plan.md` "핸들러 계약" 절) **`Relate`가 필요한 범위가 (`base/dispatch-core-plan.md` "핸들러 계약" 절) **`Relate`가 필요한 범위가
크게 줄었음** — 이 전환기에 양방향으로 실수가 나왔어서 기준을 못박아 둠. 크게 줄었음** — 이 전환기에 양방향으로 실수가 나왔어서 기준을 못박아 둠.
**쓰지 말 것 — 클로저 캡처로 충분한 경우**: 이 `process` 호출이 만든 **쓰지 말 것 — 클로저 캡처로 충분한 경우**: 이 `process` 호출이 만든
@ -111,7 +111,7 @@ Handler 계약이 "`process`가 자기 retract 클로저를 반환"으로 바뀌
- **여러 위치가 하나의 자원을 공유**할 때의 참조 카운트 — `Tag` - **여러 위치가 하나의 자원을 공유**할 때의 참조 카운트 — `Tag`
`tagNameMap`(이름 → 그 이름을 걸고 있는 위치 집합). `tagNameMap`(이름 → 그 이름을 걸고 있는 위치 집합).
- **여러 `process` 호출을 가로지르는 dedup 기록**`Ref`의 "이 자리에 - **여러 `process` 호출을 가로지르는 dedup 기록**`Ref`의 "이 자리에
마지막으로 바인딩한 Ref"(클로저`hintValue`*다음* 값이지 *이전* 마지막으로 바인딩한 Ref"(클로저가 받는 인자*다음* 값이지 *이전*
값이 아니라서 캡처로 대체 불가). 값이 아니라서 캡처로 대체 불가).
- **소유권/멤버십 전역 판정**`Slot``elementOwner`. - **소유권/멤버십 전역 판정**`Slot``elementOwner`.
- **"언제까지 실행돼도 되는가"** — `bindLifetime`/`canExecute` - **"언제까지 실행돼도 되는가"** — `bindLifetime`/`canExecute`
@ -162,7 +162,7 @@ Handler 계약이 "`process`가 자기 retract 클로저를 반환"으로 바뀌
## 대체하는 것 ## 대체하는 것
- `base/bind-system-plan.md` "핸들러 내부 상태 저장" 절의 `base.perInstanceState(inst)` - `base/dispatch-core-plan.md` "핸들러 내부 상태 저장" 절의 `base.perInstanceState(inst)`
placeholder — Tween 등 핸들러가 `retract` 대상을 저장하는 용도, `SetStrong`으로. placeholder — Tween 등 핸들러가 `retract` 대상을 저장하는 용도, `SetStrong`으로.
- `base/lifecycle-pattern.md``bindLifetime`/`canExecute` — gcconn/gchold를 - `base/lifecycle-pattern.md``bindLifetime`/`canExecute` — gcconn/gchold를
`Relate``SetStrong`으로 저장(둘 다 존재 이유가 "안 죽는 것"이므로 `Relate``SetStrong`으로 저장(둘 다 존재 이유가 "안 죽는 것"이므로

View file

@ -1,14 +1,12 @@
# Slot — 뮤터블 자식 배열, 엄격한 단일 마운트 소유권 (base로 승격됨) # Slot — 뮤터블 자식 배열, 엄격한 단일 마운트 소유권 (base로 승격됨)
> **⚠️ [2026-08-13 여섯 번째 세션] 이 문서의 `hintValue`/`retractFrom` 선행 > **✅ [2026-08-13 열네 번째 세션] 하강 diff 재디스패치 반영 완료.**
> 호출 서술은 곧 교체될 예정 — 아직 반영 안 됨.** 힌트가 `None` 센티널이나 > 이전 ⚠️ 배너가 예고하던 교체가 끝났음 — 래핑 핸들러의 `retractFrom`
> `State`/`Tween` 래퍼로 오염돼 말단 핸들러의 `isX(hint)` 가드를 거짓으로 > 선행 호출이 폐기되고 `Dispatch.process`가 핸들러를 먼저 비교하며,
> 만들고 깜빡임/재생성 방지를 조용히 끄는 결함이 확인됐고, 대체 모델 > `SlotHandler`가 반환하는 클로저가 받는 값의 **타입이 계약으로 보장**됨
> (**래핑 핸들러의 `retractFrom` 선행 호출 폐기 + `Dispatch.process` > (같은 핸들러로 재프로세스될 때만 값이 넘어오므로 항상 `Slot`이거나
> 핸들러를 먼저 비교**)까지 거의 확정됐음 — 다만 `question.md` **0-Z** > `nil`). 상세는 `base/dispatch-core-plan.md` "Dispatch 체인" 절, 옛
> (Attribute 이름 소유권) 하나가 남아 아직 옮기지 않음. **여기 적힌 대로 > 모델 원문은 `archive/dispatch-hintvalue-model-reversed.md`.
> 구현하면 옛 모델로 짜게 됨** — 반드시
> `research/dispatch-redispatch-diff-plan.md`를 먼저 읽을 것.
**상태**: base — 설계 방향(소유권 귀속, 재마운트 시 throw, **[2026-08-13 **상태**: base — 설계 방향(소유권 귀속, 재마운트 시 throw, **[2026-08-13
여섯 번째 세션 역전] retract=언마운트** — 옛 "retract=폐기"는 뒤집혔음, 여섯 번째 세션 역전] retract=언마운트** — 옛 "retract=폐기"는 뒤집혔음,
@ -225,16 +223,17 @@ Slot이 store 바인드로 들어오는 경우, pluggable 처리기에 `retract`
> 동작이 '부모 위임' 잠정안에서 '폐기(옮기지 않음)'로 확정") 참고. 이 문단은 > 동작이 '부모 위임' 잠정안에서 '폐기(옮기지 않음)'로 확정") 참고. 이 문단은
> 검토 과정의 히스토리로만 남겨둠, 현재 유효한 동작 아님. > 검토 과정의 히스토리로만 남겨둠, 현재 유효한 동작 아님.
**[전면 정정, 2026-08-12 열한 번째 세션, 2026-08-13 다섯 번째 세션에 **[전면 정정, 2026-08-12 열한 번째 세션, 2026-08-13 다섯 번째/열네 번째
클로저 반환 계약으로 서술 갱신] 위 "핸들러 타입이 안 바뀌면 retract 없이 세션에 서술 갱신] 위 "핸들러 타입이 안 바뀌면 retract 없이 process가 diff
process가 diff 담당"이라는 전제 자체가 틀렸음** — 실제로는 담당"이라는 전제 자체가 틀렸음** — store 재발행마다(핸들러가 그대로여도)
`Dispatch.retractFrom`이 store 재발행마다(핸들러가 그대로여도) **항상** **항상** 이전 `process`가 반환한 클로저가 먼저 불림. **[갱신, 열네 번째
이전 `process`가 반환한 클로저를 먼저 부름(`base/bind-system-plan.md` 세션] 부르는 주체는 `Dispatch.process` 자신**(같은 핸들러면 그 자리
"확정된 디스패치 모델"/일반 retract 계약 절 참고). 그래서 `State<Slot>` 클로저에 새 값을 넘기고 곧바로 `process`를 다시 부름 —
`slotA→slotB`로 바뀔 때도 **`slotA`를 처리했던 클로저가 `hintValue=slotB` `base/dispatch-core-plan.md` "Dispatch 체인" 절 (A) 분기). 그래서
먼저 불려 `slotA`를 폐기하고, 그 다음 `process(inst,k,slotB,index)` `State<Slot>``slotA→slotB`로 바뀔 때 **`slotA`를 처리했던 클로저가
`slotB`를 마운트**하는 두 단계로 자연히 갈림 — 클로저가 "이전 것 정리", `nextValue=slotB`로 먼저 불려 `slotA`를 언마운트하고, 그 다음
`process`가 "새 것 마운트" 전담: `process(inst,k,slotB,index)``slotB`를 마운트**하는 두 단계로 자연히
갈림 — 클로저가 "이전 것 정리", `process`가 "새 것 마운트" 전담:
**[정정, 2026-08-12 열두 번째 세션] "같은 값인가"를 위치별 relate로 **[정정, 2026-08-12 열두 번째 세션] "같은 값인가"를 위치별 relate로
간접 비교하는 대신, Slot 자신이 지금 어느 `inst`에 바인딩됐는지를 직접 간접 비교하는 대신, Slot 자신이 지금 어느 `inst`에 바인딩됐는지를 직접
@ -339,11 +338,13 @@ function SlotHandler.process(inst, k, slotValue, index)
-- 방금 새로 클레임했든, 직전 사이클의 클레임이 spurious 재발행을 거쳐 그대로 -- 방금 새로 클레임했든, 직전 사이클의 클레임이 spurious 재발행을 거쳐 그대로
-- 살아있든 둘 중 하나(다른 소유자였다면 claimOwnerAt이 이미 error). 어느 쪽이든 -- 살아있든 둘 중 하나(다른 소유자였다면 claimOwnerAt이 이미 error). 어느 쪽이든
-- "이 자리가 결국 다른 값으로 바뀔 때 할 일"은 slotValue/inst가 같아서 동일하므로 -- "이 자리가 결국 다른 값으로 바뀔 때 할 일"은 slotValue/inst가 같아서 동일하므로
-- 클로저도 하나로 통일 — 여기서 no-op 클로저를 반환하면 안 됨: retractFrom은 -- 클로저도 하나로 통일 — 여기서 no-op 클로저를 반환하면 안 됨: 체인은
-- 클로저를 early-return시키든 말든 체인에서 항상 *소비*하므로, spurious 사이클에서 -- 클로저를 early-return시키든 말든 항상 *소비*하므로(retractFrom도, 재프로세스도),
-- no-op을 심어두면 그 다음 진짜 교체 때 이전 서브트리를 정리할 주체가 사라짐. -- spurious 사이클에서 no-op을 심어두면 그 다음 진짜 교체 때 이전 서브트리를
return function(hintValue) -- 정리할 주체가 사라짐.
if slotValue == hintValue then return end -- 같은 Slot 재발행 → no-op return function(nextValue)
-- nextValue는 nil이거나 같은 핸들러가 곧 처리할 Slot — 타입 보장됨
if slotValue == nextValue then return end -- 같은 Slot 재발행 → no-op
-- [정정, 2026-08-13 감사 후속] 파괴가 아니라 **언마운트** -- [정정, 2026-08-13 감사 후속] 파괴가 아니라 **언마운트**
-- 아래 "`State<Slot>` 교체는 파괴가 아니라 언마운트" 절이 확정한 -- 아래 "`State<Slot>` 교체는 파괴가 아니라 언마운트" 절이 확정한
-- 결정이 적용돼야 하는 자리가 바로 여기(최상위 dispatch 경로). -- 결정이 적용돼야 하는 자리가 바로 여기(최상위 dispatch 경로).
@ -379,8 +380,8 @@ end
`kSlotMap`에 안 적힌 자리는 `retract`가 자연히 no-op이라 우연히 막혀 `kSlotMap`에 안 적힌 자리는 `retract`가 자연히 no-op이라 우연히 막혀
있었는데, `kSlotMap` 제거가 이 방어를 같이 걷어낸 회귀였음. 이제 있었는데, `kSlotMap` 제거가 이 방어를 같이 걷어낸 회귀였음. 이제
`claimOwnerAt``k=2`에서 곧바로 error를 내므로 그 상태 자체가 안 `claimOwnerAt``k=2`에서 곧바로 error를 내므로 그 상태 자체가 안
만들어짐 — **클로저를 두 갈래로 쪼개는 방식으로는 못 고침**: `retractFrom` 만들어짐 — **클로저를 두 갈래로 쪼개는 방식으로는 못 고침**: 체인
클로저가 early-return하든 말든 체인에서 항상 소비하므로, spurious 클로저가 early-return하든 말든 항상 소비하므로, spurious
사이클에서 no-op 클로저를 심으면 다음 진짜 교체 때 이전 서브트리를 사이클에서 no-op 클로저를 심으면 다음 진짜 교체 때 이전 서브트리를
정리할 주체가 사라져 오히려 더 큰 누수가 됨(감사 중 실제로 그 방향을 정리할 주체가 사라져 오히려 더 큰 누수가 됨(감사 중 실제로 그 방향을
먼저 써봤다가 되돌림). 먼저 써봤다가 되돌림).
@ -405,7 +406,7 @@ error가 맞음**. 반대로 top-level은 store 재발행마다 같은 Slot으
**위치를 키에 넣어도 안전한 이유(사용자 확인)** — 여기서 쓰는 `k` **위치를 키에 넣어도 안전한 이유(사용자 확인)** — 여기서 쓰는 `k`
"바깥 컨테이너 안에서의 인덱스"고, 이건 nested Slot의 `Length`가 변해도 "바깥 컨테이너 안에서의 인덱스"고, 이건 nested Slot의 `Length`가 변해도
바뀌지 않음. 실제 물리 배치의 변동은 전부 `offset`이 흡수하도록 설계돼 바뀌지 않음. 실제 물리 배치의 변동은 전부 `offset`이 흡수하도록 설계돼
있음(`base/bind-system-plan.md` "Length/Offset" 절의 `recompute`가 그 있음(`base/dispatch-core-plan.md` "Length/Offset" 절의 `recompute`가 그
증거 — `lengthList`/`sourceList`는 위치별 배열이고 순서 계산만 증거 — `lengthList`/`sourceList`는 위치별 배열이고 순서 계산만
누적합으로 함). top-level의 `k`는 특히 props 배열 리터럴의 위치라 누적합으로 함). top-level의 `k`는 특히 props 배열 리터럴의 위치라
저작 시점에 고정. **nested는 `Move`/`Swap`/`Splice`/`Remove`가 저작 시점에 고정. **nested는 `Move`/`Swap`/`Splice`/`Remove`가
@ -656,7 +657,7 @@ Remove/Extract/Move하려 해도 참조를 안 들고 있는 경우가 잦음. `
Dispatch 호출일 뿐이라 "일반적 무한루프는 방어 안 함, provider 버그로 Dispatch 호출일 뿐이라 "일반적 무한루프는 방어 안 함, provider 버그로
간주"라는 기존 원칙이 그대로 적용됨. `recompute` 자체의 재진입(같은 간주"라는 기존 원칙이 그대로 적용됨. `recompute` 자체의 재진입(같은
Slot의 length를 자기 계산 도중 다시 건드리는 것)도 같은 톤으로 UB — Slot의 length를 자기 계산 도중 다시 건드리는 것)도 같은 톤으로 UB —
`base/bind-system-plan.md`의 "Length/Offset" 절, `Source⊇State` `base/dispatch-core-plan.md`의 "Length/Offset" 절, `Source⊇State`
단방향 원칙과 같은 카테고리로 명명됨(2026-08-11 세션). 단방향 원칙과 같은 카테고리로 명명됨(2026-08-11 세션).
- **`Slot(initial?: {T})` 생성자 — [정정, 2026-08-11 세션] "인자 없는 - **`Slot(initial?: {T})` 생성자 — [정정, 2026-08-11 세션] "인자 없는
빈 생성자로 확정"을 뒤집고 초기 배열을 받는 옵션 생성자를 다시 엶.** 빈 생성자로 확정"을 뒤집고 초기 배열을 받는 옵션 생성자를 다시 엶.**
@ -810,7 +811,7 @@ fail-fast 톤으로 그 자리에서 막음 — `keyFn` 작성자(주로 위 `it
Slot이 대신 안 해주는가" 절의 예시 참고. Slot이 대신 안 해주는가" 절의 예시 참고.
- **`offset: Source<number>`** — 이 `Slot` 자신의 `Slot.Offset`을 그대로 - **`offset: Source<number>`** — 이 `Slot` 자신의 `Slot.Offset`을 그대로
전달(모든 key가 같은 값을 공유) — 형제로 섞인 다른 Slot/정적 자식이 전달(모든 key가 같은 값을 공유) — 형제로 섞인 다른 Slot/정적 자식이
기여한 개수의 누적합(`base/bind-system-plan.md`의 "Length/Offset" 절 기여한 개수의 누적합(`base/dispatch-core-plan.md`의 "Length/Offset" 절
참고). `index`와 마찬가지로 실제 프로퍼티에 어떻게 반영할지는 참고). `index`와 마찬가지로 실제 프로퍼티에 어떻게 반영할지는
전적으로 `updateFn` 몫 — `Slot`/Handler가 자동으로 해주지 않음 전적으로 `updateFn` 몫 — `Slot`/Handler가 자동으로 해주지 않음
(2026-08-11 세션 확정, 아래 참고). (2026-08-11 세션 확정, 아래 참고).
@ -1294,7 +1295,7 @@ GC에 위임" 원칙 그대로.
위 "CRUD API 확정"/"`isMounted` 이중 추적 분리"/"`Slot:List`" 절 참고. 위 "CRUD API 확정"/"`isMounted` 이중 추적 분리"/"`Slot:List`" 절 참고.
- **[해소됨, 2026-08-09 여섯 번째 세션]** 여러 Slot이 형제로 섞일 때 - **[해소됨, 2026-08-09 여섯 번째 세션]** 여러 Slot이 형제로 섞일 때
순서 보장 — 위 "여러 Slot이 섞일 때 순서 보장" 절 참고, 메커니즘은 순서 보장 — 위 "여러 Slot이 섞일 때 순서 보장" 절 참고, 메커니즘은
`base/bind-system-plan.md`의 "Length/Offset" 절이 최신 소스. `base/dispatch-core-plan.md`의 "Length/Offset" 절이 최신 소스.
## Slot.Length — `:List`뿐 아니라 항상 노출됨 (2026-08-09 여섯 번째 세션) ## Slot.Length — `:List`뿐 아니라 항상 노출됨 (2026-08-09 여섯 번째 세션)
@ -1382,7 +1383,7 @@ nil/None 금지)는 그대로.
### 재귀 메커니즘 — 새 프리미티브 없이 `Dispatch.setLength`/`setOffsetSource`를 Slot 자신 키로 재사용 ### 재귀 메커니즘 — 새 프리미티브 없이 `Dispatch.setLength`/`setOffsetSource`를 Slot 자신 키로 재사용
`base/bind-system-plan.md`의 "Length/Offset" 절이 이미 확정해둔 두 함수는 `base/dispatch-core-plan.md`의 "Length/Offset" 절이 이미 확정해둔 두 함수는
owner 키(`inst`)가 물리 Instance일 필요가 없음(`Relate`가 아무 테이블이나 owner 키(`inst`)가 물리 Instance일 필요가 없음(`Relate`가 아무 테이블이나
weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위 마운트와 weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위 마운트와
중첩 마운트가 완전히 같은 함수 호출**이 됩니다. 중첩 마운트가 완전히 같은 함수 호출**이 됩니다.
@ -1844,7 +1845,7 @@ Slot이 마운트될 때 **자기 하위 요소들까지 `bindLifetime`으로
- `Dispatch.setLength`는 끝에서 `recompute`를 돌리고, `recompute` - `Dispatch.setLength`는 끝에서 `recompute`를 돌리고, `recompute`
`sourceList`를 순회하며 각 자리의 `offset:Set(sum)`을 호출함 `sourceList`를 순회하며 각 자리의 `offset:Set(sum)`을 호출함
(`base/bind-system-plan.md` "Length/Offset" 절). (`base/dispatch-core-plan.md` "Length/Offset" 절).
- 그래서 **`setLength(0)`을 먼저 부르면**, 그 안의 `recompute`가 도는 - 그래서 **`setLength(0)`을 먼저 부르면**, 그 안의 `recompute`가 도는
시점에 해제 중인 자리의 `sourceList[i]`엔 **아직 옛 Slot의 offset 시점에 해제 중인 자리의 `sourceList[i]`엔 **아직 옛 Slot의 offset
`Source`가 그대로 남아 있음** → 지금 막 떼어내는 서브트리의 Source에 `Source`가 그대로 남아 있음** → 지금 막 떼어내는 서브트리의 Source에

View file

@ -102,7 +102,7 @@ pull-recompute)·`:Compute` 인자 규칙·State 쓰기 금지·`Source` 독립
**세 번째 카테고리 — Handler는 둘 중 어디에도 안 낌(2026-08-08 두 번째 **세 번째 카테고리 — Handler는 둘 중 어디에도 안 낌(2026-08-08 두 번째
세션, 명시화).** `Handler`(`isHandlable`/`priority`/`process` 3종 계약 — 세션, 명시화).** `Handler`(`isHandlable`/`priority`/`process` 3종 계약 —
`process`가 자기 retract 클로저를 반환, 2026-08-13 다섯 번째 세션 정정, `process`가 자기 retract 클로저를 반환, 2026-08-13 다섯 번째 세션 정정,
`base/bind-system-plan.md` "핸들러 계약" 절)는 위 분류가 다루는 `base/dispatch-core-plan.md` "핸들러 계약" 절)는 위 분류가 다루는
"quad 사용자가 직접 다루는 리액티브 값"이 아니라 **그 자체로는 구현체가 "quad 사용자가 직접 다루는 리액티브 값"이 아니라 **그 자체로는 구현체가
없는 순수 타입 계약**이라 애초에 이 분류표의 대상이 아님 — Source/Ref처럼 없는 순수 타입 계약**이라 애초에 이 분류표의 대상이 아님 — Source/Ref처럼
`Type(args)` 자유 함수로 인스턴스를 만들 수도 없고(계약을 만족하는 값은 `Type(args)` 자유 함수로 인스턴스를 만들 수도 없고(계약을 만족하는 값은

View file

@ -1,14 +1,13 @@
# Tag — array-part 값 객체, `CollectionService` 얇은 래퍼 # Tag — array-part 값 객체, 참조 카운트는 base / 엔진 호출은 주입 op
> **⚠️ [2026-08-13 여섯 번째 세션] 이 문서의 `hintValue`/`retractFrom` 선행 > **✅ [2026-08-13 열네 번째 세션] 하강 diff 재디스패치 반영 + 패키지
> 호출 서술은 곧 교체될 예정 — 아직 반영 안 됨.** 힌트가 `None` 센티널이나 > 재배치 완료.** 이전 ⚠️ 배너가 예고하던 교체가 끝났음: (1) 클로저가 받는
> `State`/`Tween` 래퍼로 오염돼 말단 핸들러의 `isX(hint)` 가드를 거짓으로 > 값의 **타입이 계약으로 보장**되어 `isTag(hintValue)` 방어 가드가
> 만들고 깜빡임/재생성 방지를 조용히 끄는 결함이 확인됐고, 대체 모델 > 불필요해졌고 깜빡임 방지가 **깊은 체인에서도 유지**됨
> (**래핑 핸들러의 `retractFrom` 선행 호출 폐기 + `Dispatch.process` > (`base/dispatch-core-plan.md` "Dispatch 체인" 절), (2) **참조 카운트
> 핸들러를 먼저 비교**)까지 거의 확정됐음 — 다만 `question.md` **0-Z** > 알고리즘 전체가 quad-base 소속**이 되고 `addTag`/`removeTag` op만
> (Attribute 이름 소유권) 하나가 남아 아직 옮기지 않음. **여기 적힌 대로 > 백엔드가 주입(아래 "패키지 배치" 절). 옛 모델 원문은
> 구현하면 옛 모델로 짜게 됨** — 반드시 > `archive/dispatch-hintvalue-model-reversed.md`.
> `research/dispatch-redispatch-diff-plan.md`를 먼저 읽을 것.
**상태**: base — 값 모양/이름은 확정. 2026-08-08 세 번째 세션에서 값 **상태**: base — 값 모양/이름은 확정. 2026-08-08 세 번째 세션에서 값
모양을 전면 재설계(구 모델은 `archive/tag-hash-key-model-reversed.md` 모양을 전면 재설계(구 모델은 `archive/tag-hash-key-model-reversed.md`
@ -16,9 +15,9 @@
`process`/`retract` 메커니즘을 참조 카운트 기반으로 전면 정정(옛 버전은 `process`/`retract` 메커니즘을 참조 카운트 기반으로 전면 정정(옛 버전은
`archive/retract-always-fires-reversed.md`). 2026-08-12 열다섯 번째 `archive/retract-always-fires-reversed.md`). 2026-08-12 열다섯 번째
세션에 `Added`/`Removed`를 단일 이름에서 `string | {string}`으로 세션에 `Added`/`Removed`를 단일 이름에서 `string | {string}`으로
정정(아래 값 모양 절). **단, 위 배너대로 `hintValue`/`retractFrom` 재-dispatch 정정(아래 값 모양 절). **[2026-08-13 열네 번째 세션] 재디스패치 모델
메커니즘은 `question.md` 0-Z 해소 대기 중 — "열린 질문 없음"은 값 (0-A)과 패키지 배치까지 반영 완료 — 이 문서에 남은 열린 질문은 이름
모양/이름에 한정.** 자체(용어 정리 대기열)뿐.**
## 왜 재설계됐나 ## 왜 재설계됐나
@ -83,7 +82,7 @@ plain string이라(핸들러 계층 값처럼 identity/생명주기가 얽힌
`store.activeTag:Compute(function(name) return (if name:Get() == "btn1" `store.activeTag:Compute(function(name) return (if name:Get() == "btn1"
then Tag("selected") else nil) end)`처럼 그냥 `nil`을 리턴하면 됨. `None` then Tag("selected") else nil) end)`처럼 그냥 `nil`을 리턴하면 됨. `None`
센티널은 "정적 테이블 리터럴에서 `키 = nil`이 키 없음과 구별 안 되는" 센티널은 "정적 테이블 리터럴에서 `키 = nil`이 키 없음과 구별 안 되는"
문제의 해법이지(`bind-system-plan.md` "`None` 센티널" 절), 이건 함수 문제의 해법이지(`dispatch-core-plan.md` "`None` 센티널" 절), 이건 함수
리턴값이 동적으로 흘러가는 경우라 그 문제 자체가 없음 — `nil`을 인자로 리턴값이 동적으로 흘러가는 경우라 그 문제 자체가 없음 — `nil`을 인자로
넘기는 건 아무 문제 없음. (단, `Frame { (if cond then Tag("a") else 넘기는 건 아무 문제 없음. (단, `Frame { (if cond then Tag("a") else
nil), sibling }`처럼 **정적 리터럴**에서 조건부로 Tag를 넣거나 빼고 nil), sibling }`처럼 **정적 리터럴**에서 조건부로 Tag를 넣거나 빼고
@ -106,13 +105,13 @@ nil`/`or None`(and/or 삼항)으로 적었으나, `Tag(...)`가 항상-truthy라
`assert(v==nil)`, "Tag(A)→Tag(B)는 retract 안 불림")을 대체함 — 그 버전은 `assert(v==nil)`, "Tag(A)→Tag(B)는 retract 안 불림")을 대체함 — 그 버전은
두 가지를 놓쳤음:** 두 가지를 놓쳤음:**
1. **`retract`는 실제로 store 재발행마다(핸들러 타입이 안 바뀌어도) 항상 1. **반환하는 클로저는 store 재발행마다(핸들러 타입이 안 바뀌어도) 항상
불림** — `bind-system-plan.md`의 "확정된 디스패치 모델" 절이 처음부터 불림** — "Tag(A)→Tag(B)는 retract 안 불림"이라는 옛 서술은 틀렸음
말해온 대로 `StoreBind`가 재-dispatch 전에 무조건 (`archive/retract-always-fires-reversed.md`). **[갱신, 2026-08-13
`Dispatch.retractFrom`(2026-08-13 다섯 번째 세션 전까지의 이름은 열네 번째 세션]** 부르는 주체는 이제 `StoreBind`의 선행 `retractFrom`
`retractUnder`)을 부르기 때문. "Tag(A)→Tag(B)는 retract 안 불림"이라는 옛 서술은 틀렸음 아니라 `Dispatch.process` 자신임 — 같은 핸들러면 그 자리 클로저에 새
(상세 근거는 `bind-system-plan.md` 일반 retract 계약 절, `archive/ 값을 넘기고 곧바로 `process`를 다시 부름(`dispatch-core-plan.md`
retract-always-fires-reversed.md`). "Dispatch 체인" 절 (A) 분기). 호출 빈도는 그대로.
2. **서로 다른 배열 위치의 두 `Tag(...)`가 같은 이름을 겹쳐 가질 수 2. **서로 다른 배열 위치의 두 `Tag(...)`가 같은 이름을 겹쳐 가질 수
있음**(`Frame { Tag("a"), Tag("a","b") }`류, 웹 `className="a a a"` 있음**(`Frame { Tag("a"), Tag("a","b") }`류, 웹 `className="a a a"`
같은 합집합 시맨틱) — 한 위치의 diff만 보고 `RemoveTag`를 부르면 다른 같은 합집합 시맨틱) — 한 위치의 diff만 보고 `RemoveTag`를 부르면 다른
@ -145,7 +144,7 @@ identity로 홀더를 추적하면 같은 객체를 두 위치(`k1`, `k2`)에
불필요해짐** — `retract`가 필요로 했던 "이 위치에 걸려 있던 Tag가 불필요해짐** — `retract`가 필요로 했던 "이 위치에 걸려 있던 Tag가
뭐였는가"는 이제 그 `process` 호출이 반환하는 클로저가 `v`를 upvalue로 뭐였는가"는 이제 그 `process` 호출이 반환하는 클로저가 `v`를 upvalue로
직접 캡처하므로, 별도 저장소에서 다시 조회할 이유가 없음(위 직접 캡처하므로, 별도 저장소에서 다시 조회할 이유가 없음(위
`base/bind-system-plan.md` "핸들러 내부 상태 저장" 절 — 단발성 handoff는 `base/dispatch-core-plan.md` "핸들러 내부 상태 저장" 절 — 단발성 handoff는
클로저로 충분). **`tagNameMap`(이름별 현재 걸고 있는 위치 집합)은 여전히 클로저로 충분). **`tagNameMap`(이름별 현재 걸고 있는 위치 집합)은 여전히
필요** — 이건 서로 다른 여러 위치를 가로지르는, `process`/클로저 하나의 필요** — 이건 서로 다른 여러 위치를 가로지르는, `process`/클로저 하나의
호출 수명을 넘어서는 누적 상태라 `Relate`가 맞는 경우: 호출 수명을 넘어서는 누적 상태라 `Relate`가 맞는 경우:
@ -164,86 +163,109 @@ function TagHandler.process(inst, k, v, index)
tagNameMap:SetStrong(inst, name, holders) tagNameMap:SetStrong(inst, name, holders)
end end
if next(holders) == nil then if next(holders) == nil then
inst:AddTag(name) addTag(inst, { name }) -- 주입된 엔진 op(아래 "패키지 배치" 절)
end end
holders[k] = true -- 이 "위치"가 이 이름을 걺(Tag 객체가 다른 위치와 같아도 무관) holders[k] = true -- 이 "위치"가 이 이름을 걺(Tag 객체가 다른 위치와 같아도 무관)
end end
return function(hintValue) return function(nextValue)
if v == hintValue then return end -- Tag는 immutable이라 객체가 안 바뀌면 -- nextValue는 nil(단순 철거)이거나 같은 핸들러가 곧 처리할 새 Tag —
-- 이름 집합도 절대 안 바뀜 — holders 순회 자체가 불필요한 순수 최적화 -- 타입이 계약으로 보장되므로 isTag 가드가 필요 없음(2026-08-13 열네 번째 세션).
local hintIsTag = isTag(hintValue) -- hintValue는 nil일 수도, 대체하는 새 Tag 자체일 수도 있음 if v == nextValue then return end -- Tag는 immutable이라 객체가 안 바뀌면
-- 이름 집합도 절대 안 바뀜 — holders 순회 자체가 불필요한 순수 최적화
local removed = {}
for name in v:Names() do for name in v:Names() do
local holders = tagNameMap:GetStrong(inst, name) -- 이미 등록됐으므로 항상 있음 local holders = tagNameMap:GetStrong(inst, name) -- 이미 등록됐으므로 항상 있음
holders[k] = nil -- 이 "위치"가 이 이름을 놓음(같은 Tag 객체를 다른 위치도 쓰고 있어도 무관) holders[k] = nil -- 이 "위치"가 이 이름을 놓음(같은 Tag 객체를 다른 위치도 쓰고 있어도 무관)
if next(holders) == nil and not (hintIsTag and hintValue:Contains(name)) then if next(holders) == nil and not (nextValue ~= nil and nextValue:Contains(name)) then
inst:RemoveTag(name) -- 곧 새 process가 재확정할 이름이면 실제 호출은 skip(깜빡임 방지) table.insert(removed, name) -- 곧 새 process가 재확정할 이름이면 skip(깜빡임 방지)
end end
end end
if #removed > 0 then
removeTag(inst, removed) -- 한 번에 — 웹 className 일괄 갱신을 위한 배치 계약
end
end end
end end
``` ```
- **`AddTag`는 온전히 `process`, `RemoveTag`는 온전히 반환하는 클로저** - **`addTag`는 온전히 `process`, `removeTag`는 온전히 반환하는 클로저**
— 서로 겹치는 diff 계산이 없음. 클로저가 이전 `Tag`(`v`, 자기 자신이 — 서로 겹치는 diff 계산이 없음. 클로저가 이전 `Tag`(`v`, 자기 자신이
캡처)가 걸었던 이름 전부를 소유 목록에서 빼되(항상 실행), 그 결과 캡처)가 걸었던 이름 전부를 소유 목록에서 빼되(항상 실행), 그 결과
목록이 비었을 때 **실제 `RemoveTag` 호출만** "새로 들어올 `hintValue` 목록이 비었을 때 **실제 `removeTag` 호출만** "새로 들어올 값이
그 이름을 여전히 Contains하는가"로 힌트를 줘서 skip — 소유 목록 자체는 그 이름을 여전히 Contains하는가"로 판단해 skip — 소유 목록 자체는
항상 최신 객체로 갱신되므로(정확히 `v`를 빼고 새 `process`가 새 값을 항상 최신 객체로 갱신되므로(정확히 `v`를 빼고 새 `process`가 새 값을
넣는 두 단계), 이름이 살아남는 경우에도 stale 레퍼런스가 안 남음. 넣는 두 단계), 이름이 살아남는 경우에도 stale 레퍼런스가 안 남음.
`process``v`가 새로 거는 이름 전부를 무조건 등록(소유 목록이 `process``v`가 새로 거는 이름 전부를 무조건 등록(소유 목록이
비어있던 경우에만 실제 `AddTag`) — 자기 나름의 old-vs-new diff가 전혀 비어있던 경우에만 실제 `addTag`) — 자기 나름의 old-vs-new diff가 전혀
필요 없음(그 일을 클로저가 매번 정확히 해줌). 필요 없음(그 일을 클로저가 매번 정확히 해줌).
- **`Tag(A)→Tag(B)`(같은 위치, 내용만 바뀜)**: `A`를 처리했던 `process` - **`Tag(A)→Tag(B)`(같은 위치, 내용만 바뀜)**: `A`를 처리했던 `process`
반환한 클로저가 `hintValue=B`로 먼저 불려 `A`가 걸었던 이름 중 `B` 반환한 클로저가 `nextValue=B`로 먼저 불려 `A`가 걸었던 이름 중 `B`
없는 것만 실제로 `RemoveTag`, 남은 건 힌트로 skip — 그 다음 없는 것만 실제로 `removeTag`, 남은 건 skip — 그 다음
`process(inst,k,B,index)``B`의 이름 전부를 등록(이미 걸려있던 `process(inst,k,B,index)``B`의 이름 전부를 등록(이미 걸려있던
이름은 `AddTag`가 no-op으로 재확인만 됨, 소유 목록엔 `B`가 새로 등록). 이름은 소유 목록이 안 비어 있어 `addTag` 자체가 안 불림, 소유 목록엔
결과적으로 실제 `RemoveTag`/`AddTag` 호출은 진짜 변경된 이름에만 `B`의 위치가 그대로 유지).
결과적으로 실제 `removeTag`/`addTag` 호출은 진짜 변경된 이름에만
일어남 — 스타일 깜빡임 방지라는 원래 목적은 그대로 달성. 일어남 — 스타일 깜빡임 방지라는 원래 목적은 그대로 달성.
**[범위 한정, 2026-08-13 감사] 이 깜빡임 방지는 "이 Tag를 바로 위에서 **[범위 확대, 2026-08-13 열네 번째 세션] 이 깜빡임 방지는 이제 깊은
직속으로 위임한 한 단계"에서만 성립함** — `Dispatch.retractFrom(inst, 체인에서도 성립함** — 옛 모델에선 힌트가 `retractFrom`이 지목한 한
k, index, v)`는 힌트 `v`를 정확히 `index` 자리에만 넘기고 그보다 깊은 자리에만 전달돼서 `State<State<Tag>>`의 바깥이 재발행하면 TagHandler가
인덱스에는 `nil`을 넘기기 때문(`bind-system-plan.md` "Dispatch 체인" `nil`을 받아 전량 `RemoveTag` 후 재`AddTag`했으나, 하강 diff에선 **각
절의 `retractFrom` 의사코드). 그래서 `State<State<Tag>>`에서 **바깥** 레벨이 자기 재프로세스에서 자기 값을 받으므로** 인덱스가 얼마나 깊든
store가 재발행하면 TagHandler(더 깊은 인덱스)는 `hintValue=nil` TagHandler는 진짜 `Tag` 객체를 받음(`dispatch-core-plan.md` "Dispatch
받아 이름 전부를 실제로 `RemoveTag`했다가 새 체인이 다시 `AddTag`함. 체인" 절의 "깊은 체인에서도 힌트가 안 사라짐").
구조상 불가피함(바깥 단계는 안쪽이 결국 어떤 Tag를 내놓을지 모름) - **`Tag(A)→nil`**: `A`의 클로저가 `nextValue=nil`로만 불림(값이 `Tag`
이고, 흔한 경로(`State<Tag>` 한 겹)는 영향 없음 — 실사용에서 문제가 아니게 돼 핸들러가 바뀌므로 `Dispatch.retractFrom` 경로) — `Contains`
되면 그때 힌트를 깊은 인덱스까지 전파하는 안을 재검토. 검사가 항상 거짓이 되어 `A`가 걸었던 이름 전부가 무조건 실제로 `removeTag`
- **`Tag(A)→nil`**: `A`의 클로저가 `hintValue=nil`로만 불림(값이 `Tag`
아니게 돼 `process`는 매치 자체가 안 됨) — `hintIsTag=false`라 힌트가
항상 거짓이 되어 `A`가 걸었던 이름 전부가 무조건 실제로 `RemoveTag`
(다른 위치가 그 이름을 계속 쓰고 있지 않다면). (다른 위치가 그 이름을 계속 쓰고 있지 않다면).
- **여러 위치가 같은 이름을 겹쳐 가지는 경우**(`Frame { Tag("a"), Tag("a","b") }`): - **여러 위치가 같은 이름을 겹쳐 가지는 경우**(`Frame { Tag("a"), Tag("a","b") }`):
두 위치가 서로 다른 `k`로 각자 독립적으로 `process`/자기 클로저를 두 위치가 서로 다른 `k`로 각자 독립적으로 `process`/자기 클로저를
타지만, `tagNameMap["a"]`는 **양쪽 위치(`k`)를 모두 담는 하나의 공유 타지만, `tagNameMap["a"]`는 **양쪽 위치(`k`)를 모두 담는 하나의 공유
집합** — 한쪽이 "a"를 잃어도 다른 쪽 위치가 집합에 남아있으면 실제 집합** — 한쪽이 "a"를 잃어도 다른 쪽 위치가 집합에 남아있으면 실제
`RemoveTag`가 안 불림. 웹 `className`처럼 손실 없는 합집합이 정확히 `removeTag`가 안 불림. 웹 `className`처럼 손실 없는 합집합이 정확히
나옴. **위치 기준이므로 두 위치가 물리적으로 같은 `Tag` 객체를 나옴. **위치 기준이므로 두 위치가 물리적으로 같은 `Tag` 객체를
재사용해도(흔한 관례) 정확히 같은 방식으로 안전 — 이게 바로 위 "정정" 재사용해도(흔한 관례) 정확히 같은 방식으로 안전 — 이게 바로 위 "정정"
절에서 객체 identity 기준을 버린 이유.** 절에서 객체 identity 기준을 버린 이유.**
- **클로저가 자기 위임 대상까지 수동으로 안 쫓아가도 됨** - **클로저가 자기 위임 대상까지 수동으로 안 쫓아가도 됨**
`Dispatch.retractFrom`이 체인 전체를 알아서 훑어주므로 TagHandler는 `Dispatch.retractFrom`이 체인 전체를 알아서 훑어주므로 TagHandler는
자기 자원(위 `tagNameMap` 하나)만 정리하면 됨. 상세 메커니즘은 자기 자원(위 `tagNameMap` 하나)만 정리하면 됨. 상세 메커니즘은
`bind-system-plan.md` "Dispatch 체인" 절. `dispatch-core-plan.md` "Dispatch 체인" 절.
## 패키지 배치 — base는 값+API, roblox는 process 글루 ## 패키지 배치 — 값도 알고리즘도 quad-base, 주입되는 건 `addTag`/`removeTag` (2026-08-13 열네 번째 세션 재배치)
**Tag의 "값 타입과 clone 체이닝 API"(`Tag(...)`/`:Added`/`:Removed`/ **Tag의 값 타입/clone 체이닝 API(`Tag(...)`/`:Added`/`:Removed`/
`:Contains`/`:Apply`/`Merged`)는 quad-base 소속** — `Modifier`와 정확히 `:Contains`/`:Apply`/`Merged`/`:Names`)가 quad-base인 건 처음부터 그대로**
같은 층위(엔진 무관, 순수 데이터+연산). `CollectionService` 실제 호출 (`Modifier`와 같은 층위 — 엔진 무관, 순수 데이터+연산). **[재배치,
(`TagHandler.process` 및 그 반환 클로저)만 quad-roblox 소속 — 이미 2026-08-13 열네 번째 세션] 여기에 더해 `TagHandler`(위 참조 카운트
확정된 "base는 인터페이스/값, backend는 process 글루" 패턴(`LifetimeHandle`, 알고리즘 전체)도 quad-base로 옮김** — 예전엔 `CollectionService` 실제
`Dispatch.addHandler` 자체가 이 패턴)을 값 타입 수준까지 그대로 확장한 호출이 있다는 이유로 핸들러 통째로 quad-roblox였으나, 엔진에 종속된 건
것뿐, 새 아키텍처 개념 아님. `AddTag`/`RemoveTag` 두 줄뿐이고 `tagNameMap` 참조 카운트는 순수 부기임.
웹에도 대응물이 있으므로(`className` 합집합) 그 배치대로면 **같은 참조
카운트 알고리즘을 백엔드마다 재구현**하게 됨.
```lua
addTag(inst: any, names: {string}): () -- 백엔드가 주입
removeTag(inst: any, names: {string}): ()
```
- **`{string}`을 받는 이유**: 호출자는 항상 quad 자신이고 넘기는 것도
"이번 사이클에 실제로 추가/제거된 이름 집합"이라 테이블이 자연 단위임.
vararg면 `table.unpack(t)`**인자 목록 tail 위치일 때만** 완전히
펼쳐진다는 Lua 문법 제약에 걸리는데, 이건 이미 `Tag:Added`
vararg → `string | {string}`으로 되돌아갔던 것과 **같은 이유**(위 "값
모양" 절). 배치 호출 자체는 테이블로도 되므로 웹 `className` 일괄
갱신 요구도 그대로 충족됨.
- **등록 우선순위는 `HANDLER_PRIORITY_FALLBACK`** — 특정 백엔드가 태그
처리를 통째로 다르게 하고 싶으면 평범한 우선순위로 자기 핸들러를
등록하면 자동으로 이김. 주입 op이 없는 백엔드에서는 base 스텁이
명확한 에러를 냄. 일반 원칙과 근거는 `base/dispatch-core-plan.md`
"base가 소유하는 핸들러와 주입되는 엔진 op" 절 — `Attribute`도 정확히
같은 구조(`base/attribute-plan.md`).
- 이건 새 아키텍처 개념이 아니라 이미 확정된 "base는 인터페이스/값,
backend는 구현"(`LifetimeHandle`의 `bindLifetime`/`canExecute`,
`Dispatch.addHandler` 자체가 그 패턴)을 핸들러 층까지 밀어붙인 것.
## 열린 질문 ## 열린 질문
값 모양/이름(`Tag`/`Added`/`Removed`/`Merged`, 용어 정리 대상, 값 모양/메커니즘/패키지 배치엔 **[2026-08-13 열네 번째 세션 기준] 없음** —
`.claude/question.md`) 자체는 없음. **단, 이 문서 최상단 배너가 이미 재디스패치 모델(`question.md` 0-A)과 패키지 재배치까지 전부 반영 완료.
경고하듯 `hintValue`/`retractFrom` 선행 호출 메커니즘은 `question.md` 남은 건 이름 자체(`Tag`/`Added`/`Removed`/`Merged`)가 용어 정리
**0-Z**(Attribute 이름 소유권) 해소 대기 중인 "하강 diff" 재디스패치 대기열(`.claude/question.md` 1번)에 있다는 것뿐.
모델로 교체 예정** — `research/dispatch-redispatch-diff-plan.md` 6절이
이 파일을 반영 대상으로 명시 지목(`isTag(hintValue)` 가드 제거 등).
이 절이 예전엔 "없음"으로만 적혀 있었던 건 그 배너가 붙기 전 작성된
서술이 안 갱신된 stale — 재발 방지용으로 여기 명시.

View file

@ -118,7 +118,7 @@ base가 범용 유틸로 제공하기로 확정한 per-instance weak-keyed 저
"핸들러 내부 상태 저장" 절)를 그대로 재사용하면 됨 — PropertyHandler가 실행 중인 "핸들러 내부 상태 저장" 절)를 그대로 재사용하면 됨 — PropertyHandler가 실행 중인
Tween 상태를 기억해두는 것과 정확히 같은 패턴. 새 메커니즘 발명 불필요, 이미 Tween 상태를 기억해두는 것과 정확히 같은 패턴. 새 메커니즘 발명 불필요, 이미
있는 "store 바인드는 pluggable 바인드를 재실행하는 래핑" 원칙 있는 "store 바인드는 pluggable 바인드를 재실행하는 래핑" 원칙
(`base/bind-system-plan.md` "확정된 디스패치 모델" 절)이 그대로 적용됨. (`base/dispatch-core-plan.md` "확정된 디스패치 모델" 절)이 그대로 적용됨.
## 패키지 배치 — `quad-roblox` 코어에 직접 포함, 확정 ## 패키지 배치 — `quad-roblox` 코어에 직접 포함, 확정

View file

@ -67,10 +67,10 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
| 파일 | 검증 대상 | 근거 문서 | | 파일 | 검증 대상 | 근거 문서 |
|---|---|---| |---|---|---|
| `01-two-pass-array-hash-order.luau` | 배열 파트(children/Ref) 먼저, 해시 파트(프로퍼티/이벤트) 나중이라는 두 패스 순회 계약 | `bind-system-plan.md` "props 순회 순서", ROADMAP M0-4 | | `01-two-pass-array-hash-order.luau` | 배열 파트(children/Ref) 먼저, 해시 파트(프로퍼티/이벤트) 나중이라는 두 패스 순회 계약 | `dispatch-core-plan.md` "props 순회 순서", ROADMAP M0-4 |
| `02-none-sentinel-vs-nil-holes.luau` | **[2026-08-09 커밋 f198fd9 반영해 전면 재작성]** 순서가 중요한 배열(PreRef pre-pass, sourceList)은 `None` 소진이 맞고, 순서가 안 중요하고 재사용이 필요한 배열(Ref 콜백/대기자)은 `nil`+슬롯 재사용이 맞다는 최종 구분 + `None`을 잘못 쓰면 배열이 무한정 자라는 버그의 정량적 재현 | `bind-system-plan.md` "왜 None이 아니라 nil인가"(2026-08-09 열한 번째 세션 최종 정정), ROADMAP M0-4 | | `02-none-sentinel-vs-nil-holes.luau` | **[2026-08-09 커밋 f198fd9 반영해 전면 재작성]** 순서가 중요한 배열(PreRef pre-pass, sourceList)은 `None` 소진이 맞고, 순서가 안 중요하고 재사용이 필요한 배열(Ref 콜백/대기자)은 `nil`+슬롯 재사용이 맞다는 최종 구분 + `None`을 잘못 쓰면 배열이 무한정 자라는 버그의 정량적 재현 | `bind-system-plan.md` "왜 None이 아니라 nil인가"(2026-08-09 열한 번째 세션 최종 정정), ROADMAP M0-4 |
| `03-recursive-store-bind-dispatch.luau` | `process`/`retract` 재귀 재-dispatch 기본 모델, 우선순위 스캔 | `bind-system-plan.md` "확정된 디스패치 모델", ROADMAP M0-3 | | `03-recursive-store-bind-dispatch.luau` | `process`/`retract` 재귀 재-dispatch 기본 모델, 우선순위 스캔 | `dispatch-core-plan.md` "확정된 디스패치 모델", ROADMAP M0-3 |
| `04-dispatch-chain-retractFrom.luau` | **[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" 가드를 검증했는데 그 가드는 다섯 번째 세션 재설계로 **없어져서** 설계와 정반대를 테스트하고 있었음 | `bind-system-plan.md` "Dispatch 체인"(2026-08-13 다섯 번째 세션 재설계) + 2026-08-13 감사 | | `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가 다이아몬드 의존성에서 중복 재계산 없이 동작하는지 | ROADMAP M0-1 | | `05-store-state-diamond-propagation.luau` | push-invalidate/pull-recompute가 다이아몬드 의존성에서 중복 재계산 없이 동작하는지 | ROADMAP M0-1 |
| `06-component-boundary-nil-hole-props.luau` | `props.Modifier or None` 관용구가 컴포넌트 경계 nil-hole을 막는지 + `Params` 타입 체크 | `component-composition-plan.md` "필수 관용구", ROADMAP M0-5 | | `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`에서 사라짐 | | `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`에서 사라짐 |
@ -85,7 +85,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
| `16-type-store-key-typefunction.luau` (타입체크 전용) | `Store<T>``T`의 각 필드를 `Source`로 감싼 타입을 Luau `type function`(`types.newtable`/`:setproperty`/`ty:properties()`)으로 실제 합성 가능한지, 결과가 구조적으로 `Source<T>` 필드를 만족하는지 | `bind-system-plan.md` "`store.key` 레코드 필드 타이핑" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-10 | | `16-type-store-key-typefunction.luau` (타입체크 전용) | `Store<T>``T`의 각 필드를 `Source`로 감싼 타입을 Luau `type function`(`types.newtable`/`:setproperty`/`ty:properties()`)으로 실제 합성 가능한지, 결과가 구조적으로 `Source<T>` 필드를 만족하는지 | `bind-system-plan.md` "`store.key` 레코드 필드 타이핑" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-10 |
| `17-modifier-index-tableclone-chaining.luau` | Modifier의 제네릭 `__index`+`table.clone` 체이닝 — 임의 필드 이름에 대해 즉석 setter가 만들어지는지, `table.clone`이 메타테이블을 참조로 공유해 여러 단계 clone에서도 체이닝이 안 끊기는지, 원본이 mutate 안 되는지, 형제 분기끼리 오염 안 되는지 | `modifier-plan.md` "런타임은 클래스별 코드 없이 base에 딱 하나만 있으면 됨" 절 + "`table.clone`의 정확한 동작 — 확인됨" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-11 | | `17-modifier-index-tableclone-chaining.luau` | Modifier의 제네릭 `__index`+`table.clone` 체이닝 — 임의 필드 이름에 대해 즉석 setter가 만들어지는지, `table.clone`이 메타테이블을 참조로 공유해 여러 단계 clone에서도 체이닝이 안 끊기는지, 원본이 mutate 안 되는지, 형제 분기끼리 오염 안 되는지 | `modifier-plan.md` "런타임은 클래스별 코드 없이 base에 딱 하나만 있으면 됨" 절 + "`table.clone`의 정확한 동작 — 확인됨" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-11 |
| `18-relate-mutual-cycle-gc.luau` | **[2026-08-13 신규]** 서로 다른 두 `Relate`가 서로의 키를 상대방의 강한 값으로 제공하는 상호 순환은 Luau에 ephemeron이 없어 GC가 못 푼다는 주장(지금까지 공식 문서 인용으로만 뒷받침됨) — 음성 대조군(순환 재현)과 양성 대조군(한쪽을 weak-value로 낮추면 풀리는지) 둘 다 실측 | `relate-plan.md` "위험한 패턴" 절(2026-08-12 열세/열네 번째 세션), `slot-plan.md``kSlotMap`/`slotOwner`/`elementOwner` 실사례 | | `18-relate-mutual-cycle-gc.luau` | **[2026-08-13 신규]** 서로 다른 두 `Relate`가 서로의 키를 상대방의 강한 값으로 제공하는 상호 순환은 Luau에 ephemeron이 없어 GC가 못 푼다는 주장(지금까지 공식 문서 인용으로만 뒷받침됨) — 음성 대조군(순환 재현)과 양성 대조군(한쪽을 weak-value로 낮추면 풀리는지) 둘 다 실측 | `relate-plan.md` "위험한 패턴" 절(2026-08-12 열세/열네 번째 세션), `slot-plan.md``kSlotMap`/`slotOwner`/`elementOwner` 실사례 |
| `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}`을 조용히 통과시키는 걸 재현. **캐비엇**: B는 `question.md` 0-Z(하강 diff 모델에선 그룹↔그룹을 점유 체크만으론 못 잡음)가 정해지면 다시 손봐야 함 | `tag-plan.md` "메커니즘", `attribute-plan.md` "이름 소유권", `slot-plan.md` "요소 소유권" | | `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 열다섯 번째 세션), `bind-system-plan.md``recompute` off-by-one 수정 사례(2026-08-11 여섯 번째 세션) | | `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 열다섯 번째 세션), `bind-system-plan.md``recompute` off-by-one 수정 사례(2026-08-11 여섯 번째 세션) |
## 공통 유틸리티 ## 공통 유틸리티
@ -179,7 +179,7 @@ Luau로 재현해본 적이 없었던 갭 — `Slot`의 `kSlotMap`/`slotOwner` G
`retractUnder`의 꼬리부터-cutoff 로직을 직접 되짚다가 "같은 키에서 핸들러가 `retractUnder`의 꼬리부터-cutoff 로직을 직접 되짚다가 "같은 키에서 핸들러가
재사용되면 문제 아닌가"를 제기 — 손으로 트레이싱해 `State<State<T>>`(store가 재사용되면 문제 아닌가"를 제기 — 손으로 트레이싱해 `State<State<T>>`(store가
emit하는 값 자체가 또 State/Source)가 실제로 체인을 파손시킴을 확인 emit하는 값 자체가 또 State/Source)가 실제로 체인을 파손시킴을 확인
(`bind-system-plan.md` "확정된 디스패치 모델" 절 신규 항목). 기존 `04` (`dispatch-core-plan.md` "확정된 디스패치 모델" 절 신규 항목). 기존 `04`
정확히 이 시나리오(store-in-store)를 이미 스트레스 테스트로 다루고 있었지만, 정확히 이 시나리오(store-in-store)를 이미 스트레스 테스트로 다루고 있었지만,
`retract`가 print만 하는 no-op 스텁이라 자기-retract 버그의 실제 증상(구독이 `retract`가 print만 하는 no-op 스텁이라 자기-retract 버그의 실제 증상(구독이
조용히 끊기는 것)을 절대 드러낼 수 없었다는 사각지대도 같이 발견 — 조용히 끊기는 것)을 절대 드러낼 수 없었다는 사각지대도 같이 발견 —

View file

@ -1,6 +1,7 @@
# 스파이크 상태판 — **폴더가 곧 상태** # 스파이크 상태판 — **폴더가 곧 상태**
> 마지막 갱신: 2026-08-13 열세 번째 세션(`08` 해소 → `done/`, `review-required/` 비워짐). > 마지막 갱신: 2026-08-13 **열네 번째 세션**(하강 diff 재디스패치 확정으로
> `04`/`19`가 옛 모델을 검증하고 있어 `rewrite-required/`로 이동).
> 첫 실측은 여섯 번째 세션 — 상세 결과는 `.claude/audit/luau-test-first-run-2026-08-13.md`. > 첫 실측은 여섯 번째 세션 — 상세 결과는 `.claude/audit/luau-test-first-run-2026-08-13.md`.
> 실행법: `luau <파일>` (런타임) / `luau-analyze <파일>` (타입 전용). > 실행법: `luau <파일>` (런타임) / `luau-analyze <파일>` (타입 전용).
@ -11,9 +12,9 @@
| 폴더 | 뜻 | 개수 | 누가 처리 | | 폴더 | 뜻 | 개수 | 누가 처리 |
|---|---|---|---| |---|---|---|---|
| `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 | | `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 |
| `rewrite-required/` | 스파이크 코드가 깨짐(설계 문제 **아님**) | 3 | 에이전트 | | `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 5 | 에이전트 |
| `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 1(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 | | `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 1(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 |
| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 16 | — | | `done/` | 통과 or 판정 끝, 더 할 일 없음 | 14 | — |
**폴더를 옮기는 게 곧 상태 갱신** — 스파이크를 고치거나 돌렸으면 파일을 **폴더를 옮기는 게 곧 상태 갱신** — 스파이크를 고치거나 돌렸으면 파일을
해당 폴더로 `git mv`하고 아래 표의 줄도 같이 옮길 것. 파일별 "무엇을 왜 해당 폴더로 `git mv`하고 아래 표의 줄도 같이 옮길 것. 파일별 "무엇을 왜
@ -37,10 +38,19 @@
`rewrite-required/`에 그대로 둠 — 재작성 대상이지 사람 결정 대상이 `rewrite-required/`에 그대로 둠 — 재작성 대상이지 사람 결정 대상이
아님(계약 자체는 위에서 이미 확정됨). 아님(계약 자체는 위에서 이미 확정됨).
## 🟠 `rewrite-required/` — 스파이크가 깨짐, 설계 문제 아님 (3건) ## 🟠 `rewrite-required/` — 스파이크가 낡음 (5건)
**[2026-08-13 열네 번째 세션] 앞의 두 건은 "코드가 깨진" 게 아니라 "설계가
바뀐" 경우** — `question.md` 0-A/0-Z 확정으로 재디스패치가 **하강 diff**가
되면서, 이 둘이 검증하던 전제(선행 `retractFrom` + 4-인자 힌트 + 인덱스
점유 체크)가 더 이상 설계가 아님. 통과 상태로 `done/`에 두면 **옛 모델을
"검증됨"으로 오독하게 되므로** 옮김. 새 정본은
`base/dispatch-core-plan.md`/`base/attribute-plan.md`.
| 파일 | 상태 | 무엇을 고쳐야 하나 | | 파일 | 상태 | 무엇을 고쳐야 하나 |
|---|---|---| |---|---|---|
| `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는 손댈 것 없음 |
| `13-type-ref-preref-subtype.luau` | 타입 A섹션 ✅ 통과 / **런타임 B섹션 실행 불가** | B가 A의 더미 스텁(`fakePreRef = nil`)에 막혀 도달 못 함 — 두 섹션을 파일로 분리 | | `13-type-ref-preref-subtype.luau` | 타입 A섹션 ✅ 통과 / **런타임 B섹션 실행 불가** | B가 A의 더미 스텁(`fakePreRef = nil`)에 막혀 도달 못 함 — 두 섹션을 파일로 분리 |
| `15-type-compute-trailing-deps-typepack.luau` | **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 | | `15-type-compute-trailing-deps-typepack.luau` | **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 |
| `16-type-store-key-typefunction.luau` | ❌ 실패 | `types.newfunction` 시그니처가 설치된 버전의 실제 API와 안 맞음 — 실제 API 재확인 후 재시도 | | `16-type-store-key-typefunction.luau` | ❌ 실패 | `types.newfunction` 시그니처가 설치된 버전의 실제 API와 안 맞음 — 실제 API 재확인 후 재시도 |
@ -52,23 +62,23 @@
| `10-roblox-studio-checks.server.luau` | **Studio 전용**(`luau` CLI로 못 돌림). A 섹션 앞부분만 사용자 자작 스크립트로 실측 — `audit/gcconn-trick-verification.md`. **A-1/A-2(`canBound` 게이트)/B/C는 여전히 미확인** | | `10-roblox-studio-checks.server.luau` | **Studio 전용**(`luau` CLI로 못 돌림). A 섹션 앞부분만 사용자 자작 스크립트로 실측 — `audit/gcconn-trick-verification.md`. **A-1/A-2(`canBound` 게이트)/B/C는 여전히 미확인** |
| `gc-trigger-helper.server.luau` | 스파이크가 아니라 **헬퍼** — Studio에 `collectgarbage()`가 없어서 GC를 강제 트리거하는 기법. `10`을 돌릴 때 같이 씀 | | `gc-trigger-helper.server.luau` | 스파이크가 아니라 **헬퍼** — Studio에 `collectgarbage()`가 없어서 GC를 강제 트리거하는 기법. `10`을 돌릴 때 같이 씀 |
## ✅ `done/` — 통과 or 판정 끝 (16건) ## ✅ `done/` — 통과 or 판정 끝 (14건)
**런타임 12개 전원 통과**(crash 0 / FAIL 0): **런타임 12개 전원 통과**(crash 0 / FAIL 0) — **[열네 번째 세션] 그중
`04`/`19`는 검증 대상 설계가 바뀌어 위 `rewrite-required/`로 이동했고,
아래 표엔 남은 10개만 있음**(실측 당시 통과였다는 사실 자체는 유효):
| 파일 | 확인된 것 | | 파일 | 확인된 것 |
|---|---| |---|---|
| `01-two-pass-array-hash-order` | 배열 파트 전체 → 해시 파트 순. `Dispatch.drive` 두 패스 계약과 `PreRef` 호이스팅의 전제 | | `01-two-pass-array-hash-order` | 배열 파트 전체 → 해시 파트 순. `Dispatch.drive` 두 패스 계약과 `PreRef` 호이스팅의 전제 |
| `02-none-sentinel-vs-nil-holes` | `nil` 소진 시 `#t` 50→49로 무너짐 / `None`은 항상 50. 반대로 Ref 콜백 배열은 `None` 쓰면 죽은 슬롯 1000개 잔존 — **두 배열의 규칙이 서로 반대여야 함**이 정량 확인 | | `02-none-sentinel-vs-nil-holes` | `nil` 소진 시 `#t` 50→49로 무너짐 / `None`은 항상 50. 반대로 Ref 콜백 배열은 `None` 쓰면 죽은 슬롯 1000개 잔존 — **두 배열의 규칙이 서로 반대여야 함**이 정량 확인 |
| `03-recursive-store-bind-dispatch` | StoreBind 재귀 재-dispatch, `None`→`nil` 흐름, 무한재귀 없이 종료 | | `03-recursive-store-bind-dispatch` | StoreBind 재귀 재-dispatch, `None`→`nil` 흐름, 무한재귀 없이 종료 |
| `04-dispatch-chain-retractFrom` | 인덱스 기반 체인 + **음성 대조군이 감사 버그를 재현**(아래 별도 절) |
| `05-store-state-diamond-propagation` | 다이아몬드에서 재계산 정확히 1회, invalidate 2번째는 즉시 중단 | | `05-store-state-diamond-propagation` | 다이아몬드에서 재계산 정확히 1회, invalidate 2번째는 즉시 중단 |
| `06-component-boundary-nil-hole-props` | `or None` 없으면 앞쪽 nil-hole로 슬롯 소실, 관용구 쓰면 항상 보존 | | `06-component-boundary-nil-hole-props` | `or None` 없으면 앞쪽 nil-hole로 슬롯 소실, 관용구 쓰면 항상 보존 |
| `07-relate-weak-table-gc` | **연쇄 GC 확정**(아래 별도 절) — GC-native 아키텍처의 핵심 전제 | | `07-relate-weak-table-gc` | **연쇄 GC 확정**(아래 별도 절) — GC-native 아키텍처의 핵심 전제 |
| `11-modifier-illegal-value-error` | Modifier 필드/Source에 핸들러 계층 값 넣으면 즉시 error — 16개 케이스 전원 | | `11-modifier-illegal-value-error` | Modifier 필드/Source에 핸들러 계층 값 넣으면 즉시 error — 16개 케이스 전원 |
| `17-modifier-index-tableclone-chaining` | 제네릭 `__index` + `table.clone` 체이닝, 메타테이블 참조 공유, 형제 분기 무오염 | | `17-modifier-index-tableclone-chaining` | 제네릭 `__index` + `table.clone` 체이닝, 메타테이블 참조 공유, 형제 분기 무오염 |
| `18-relate-mutual-cycle-gc` | **두 `Relate` 상호 순환은 실제로 GC 안 됨**(아래 별도 절) | | `18-relate-mutual-cycle-gc` | **두 `Relate` 상호 순환은 실제로 GC 안 됨**(아래 별도 절) |
| `19-ownership-refcount-relate-patterns` | Tag 참조 카운트 / Attribute 점유 체크 / Slot `claimOwner` vs `claimOwnerAt`**음성 대조군 포함** 전원 통과. ⚠️ **B 섹션은 `question.md` 0-Z가 정해지면 다시 손봐야 함**(하강 diff 모델에선 그룹↔그룹을 점유 체크만으론 못 잡음) |
| `20-slot-splice-index-arithmetic` | `Splice` 산술 11개 경계 케이스 전부 참조 구현과 일치 | | `20-slot-splice-index-arithmetic` | `Splice` 산술 11개 경계 케이스 전부 참조 구현과 일치 |
**타입 스파이크 중 판정이 끝나 더 할 일 없는 것**: **타입 스파이크 중 판정이 끝나 더 할 일 없는 것**:
@ -82,7 +92,8 @@
### 특별히 중요한 통과 3건 ### 특별히 중요한 통과 3건
**`04` — 직전 감사가 찾은 버그가 음성 대조군으로 재현됨** **`04` — 직전 감사가 찾은 버그가 음성 대조군으로 재현됨**(파일은 지금
`rewrite-required/`에 있음 — 아래 관측 자체는 새 모델에서도 유효)
| 관측 지점 | 정상(수정본) | 대조군(버그) | | 관측 지점 | 정상(수정본) | 대조군(버그) |
|---|---|---| |---|---|---|

View file

@ -12,68 +12,25 @@
--- ---
## ⭐ 최우선 — M0 착수를 막고 있음 (1건) ## ⭐ 최우선 — **없음** (2026-08-13 열네 번째 세션 기준)
이 항목은 사용자가 **직접 스케치하며 판단하겠다고 명시 이관**한 것이라 > **M0 착수를 막던 항목이 전부 해소됐습니다.** `0-Y`(`:Compute(fn)`의
에이전트가 기본값으로 밀고 갈 수 없음. 루트 `HUMAN_TODO.md` 4번에도 있음. > lazy 핸들 계약)는 열세 번째 세션에, `0-Z`(Attribute 이름 소유권)와
> `0-A`(재디스패치 하강 diff)는 **열네 번째 세션에 확정·`base/` 반영
> **[2026-08-13 열세 번째 세션] 여기 같이 있던 `0-Y`(`:Compute(fn)`의 > 완료**. 해소 전 원문과 결론은 `archive/question-resolved.md`,
> lazy 핸들 계약)는 해소됨** — 실측 결론은 "계약은 그대로 두고, 파생 > 뒤집힌 옛 재디스패치 모델은 `archive/dispatch-hintvalue-model-reversed.md`.
> State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩한다. 그 외는 >
> Luau의 현 한계라 지금 우리가 할 수 있는 바 없다". 지금 유효한 규약은 > **M0 착수 전 읽을 것**: `base/typing-limits.md`(0-Y가 남긴 구현 규약),
> **`base/typing-limits.md`**, 실측 근거 전문은 > `base/dispatch-core-plan.md`(0-A/0-Z가 반영된 디스패치 코어 — 열네 번째
> `audit/type-recursion-issue/`, 해소 전 원문은 > 세션에 `bind-system-plan.md`에서 분리 신설).
> `archive/question-resolved.md`의 0-Y 절.
### 0-Z. ⭐ **최우선 — Attribute 이름 소유권을 무엇으로 판정할 것인가** (2026-08-13 여섯 번째 세션, 사용자가 다음 세션 심층 분석으로 이관)
**이게 지금 유일하게 `base/` 반영을 막고 있는 결정.** 아래 0-A의
재디스패치 모델은 나머지가 전부 확정됐고, 이 항목 하나만 정해지면
⚠️ 배너를 단 **7개 문서**(`bind-system-plan.md`/`tag-plan.md`/
`slot-plan.md`/`attribute-plan.md`/`ref-plan.md`/`architecture.md`/
`ROADMAP.md``ref-plan.md`는 9차 세션 분할 때 배너가 같이 안 옮겨간
걸 10차 세션 감사에서 발견해 추가)를 한 번에 옮기면 됨.
**문제**: 새 모델(핸들러 선비교)에서는 그룹 A가 잡아둔
`AttributeKey("foo")` 인덱스 1에 그룹 B가 들어와도 **양쪽 다 `StoreBind`
매치되므로 "같은 핸들러"로 판정돼 조용히 갈아탐.** 그리고 나중에 A의
클로저가 자기 이름들을 `retractFrom`할 때 B의 바인딩을 대신 철거함
(교차 오염). 예전 "조용한 last-write-wins"가 그대로 돌아옴 — 이번 감사에서
고쳤던 바로 그 증상. 즉 **Dispatch의 점유 체크가 대신 잡아주던 걸 이제
Attribute가 직접 해야 함.**
**사용자 방향(2026-08-13, 심층 분석은 다음 세션)**:
> "Attribute 소유권은 아마 이전 결정을 다시 가져오는게 맞아보이긴 하네요.
> 막 깊게 Key -> Group 필요한것 같지는 않고, 본인 retract 처리를 수행할 때
> 무언가 하면 될듯 한데. 이 부분은 나중에 제가 물리적으로 스케치 해보며
> 심층 분석해보겠습니다."
- **"이전 결정을 다시 가져온다"** = 2026-08-13 네 번째 세션의 이름별
claimant `Relate`(당시 이름 `owners`). **당시 기각 사유는 새 모델에서
구조적으로 소멸함** — 그때 버그는 "소유권 반납이 `process``v==nil`
분기에만 있어서, 그룹이 이름을 통째로 놓는 경로가 그 분기를 안 타
옛 소유권이 안 지워짐"이었는데, 지금은 **클로저가 항상 불리므로 거기서
반납**하면 그 구멍이 안 생김. 사용자의 "본인 retract 처리를 수행할 때
무언가 하면 될듯"이 정확히 이 지점.
- **"막 깊게 Key → Group 필요한 것 같지는 않다"** — 키에서 그룹으로
거슬러 올라가는 양방향 레지스트리까지는 필요 없고, 이름 → 현재
claimant 단방향이면 충분할 것이라는 방향.
- 원문 맥락과 기각된 두 중간안(`rawNew` 전용 키, `AttributeGroupKeyHandler`
체크포인트)은 `archive/checkpoint-handler-pattern-reversed.md`,
분석은 `research/dispatch-redispatch-diff-plan.md` 5절.
**대안 후보(정리해둠)**: (a) 이름별 claimant `Relate`를 Attribute에
국소적으로 — 권고, (b) UB로 두고 문서로만 금지 — 증상이 "조용한 오작동 +
교차 오염"이라 다른 UB들(즉시 스택오버플로/즉시 error)보다 나빠서 비권장,
(c) `Dispatch`에 claimant 개념 일반화 — 이번에 걷어낸 방향이라 반대.
## 결정 대기 — M0는 안 막음 ## 결정 대기 — M0는 안 막음
### 0-W. 같은 `Ref` 객체가 두 자리에 놓이는 걸 막을 것인가 (2026-08-13 열세 번째 세션 신설, 0-Z 확인 중 발견) ### 0-W. 같은 `Ref` 객체가 두 자리에 놓이는 걸 막을 것인가 (2026-08-13 열세 번째 세션 신설, 0-Z 확인 중 발견)
**0-Z(Attribute)를 보다가 "Ref에도 같은 문제가 있냐"는 사용자 질문에서 **0-Z(Attribute, 열네 번째 세션에 해소됨)를 보다가 "Ref에도 같은 문제가
나온 것 — 있고, 막는 장치가 전혀 없음.** 단 메커니즘은 0-Z와 **반대 있냐"는 사용자 질문에서 나온 것 — 있고, 막는 장치가 전혀 없음.** 단
방향**이라 별개 항목으로 분리: Attribute는 *두 소유자 → 한 자리*(이름별 메커니즘은 0-Z와 **반대 방향**이라 별개 항목으로 분리: Attribute는 *두 소유자 → 한 자리*(이름별
메모이즈된 키라 수렴), Ref는 *한 객체 → 두 자리*(발산). 메모이즈된 키라 수렴), Ref는 *한 객체 → 두 자리*(발산).
**손 트레이싱**(`base/ref-plan.md`의 `RefLeafHandler` 의사코드에 대입, **손 트레이싱**(`base/ref-plan.md`의 `RefLeafHandler` 의사코드에 대입,
@ -82,7 +39,7 @@ Attribute가 직접 해야 함.**
1. `process(inst1,"Ref",r)``relate[inst1]["Ref"]`가 nil → `r:Set(inst1)` 1. `process(inst1,"Ref",r)``relate[inst1]["Ref"]`가 nil → `r:Set(inst1)`
2. `process(inst2,"Ref",r)``relate[inst2]["Ref"]`도 nil(**다른 키**) → 2. `process(inst2,"Ref",r)``relate[inst2]["Ref"]`도 nil(**다른 키**) →
`r:Set(inst2)` — inst1 바인딩이 **조용히 유실, 에러 없음** `r:Set(inst2)` — inst1 바인딩이 **조용히 유실, 에러 없음**
3. inst1 자리가 retract → `hintValue(nil) ~= v(r)` → **`r:Set(nil)`** — 3. inst1 자리가 retract → 클로저 인자 `nil ~= v(r)` → **`r:Set(nil)`** —
inst2가 정당하게 들고 있던 값을 지움(교차 오염) inst2가 정당하게 들고 있던 값을 지움(교차 오염)
`relate``(inst,k)`별로만 있어 "이 Ref가 이미 다른 자리에 있다"를 `relate``(inst,k)`별로만 있어 "이 Ref가 이미 다른 자리에 있다"를
@ -95,7 +52,7 @@ Attribute가 직접 해야 함.**
| `Slot` | element | `claimOwner`/`claimOwnerAt` → 즉시 error(`Slot{a,a}`/`Frame{slot,slot}`) | 막힘 | | `Slot` | element | `claimOwner`/`claimOwnerAt` → 즉시 error(`Slot{a,a}`/`Frame{slot,slot}`) | 막힘 |
| `PreRef` | 자기 자신 | `_fired` → 재사용 시 error | 막힘 | | `PreRef` | 자기 자신 | `_fired` → 재사용 시 error | 막힘 |
| `Tag` | 태그 이름 | 위치별 참조 카운트 — 겹침이 **의도된 동작**(합집합) | 설계상 정상 | | `Tag` | 태그 이름 | 위치별 참조 카운트 — 겹침이 **의도된 동작**(합집합) | 설계상 정상 |
| `Attribute` | 이름 | 없음 | **0-Z** | | `Attribute` | 이름 | 그룹 전용 키 + 이름 claim → 즉시 error | 막힘(0-Z 해소, 14차 세션) |
| **`Ref`** | 자기 자신 | **없음** | **이 항목** | | **`Ref`** | 자기 자신 | **없음** | **이 항목** |
특히 걸리는 두 가지: 특히 걸리는 두 가지:
@ -107,48 +64,18 @@ Attribute가 직접 해야 함.**
검증하는데 **`Ref`만 커버가 없음**. 검증하는데 **`Ref`만 커버가 없음**.
**0-Z와의 관계 — 독립**: 이건 하강 diff 모델이 만든 회귀가 **아니라 **0-Z와의 관계 — 독립**: 이건 하강 diff 모델이 만든 회귀가 **아니라
원래부터 있던 갭**(점유 체크는 같은 `(inst,k,index)`만 봤지, 서로 다른 원래부터 있던 갭**(옛 점유 체크도 같은 `(inst,k,index)`만 봤지, 서로 다른
자리를 가로지르는 건 원래 안 봤음). 0-Z를 어떻게 정하든 별도 결정이고, 자리를 가로지르는 건 원래 안 봤음). **[2026-08-13 열네 번째 세션] 0-Z가
0-Z와 달리 **M0를 막지 않음** — 다만 사용자가 소유권 설계를 스케치할 때 해소되면서 이 표에서 `Ref`만 유일하게 비어 있게 됐음** — Attribute는
같이 보는 게 자연스러움. "이름 claim"이라는 국소 레지스트리로 갔으니, `Ref`도 같은 모양(`Ref →
현재 자리` 단방향 `Relate` + 즉시 error)이 자연스러운 선택지. 여전히
**M0를 막지는 않음**.
**선택지**: (a) `Slot`/`PreRef`와 같이 즉시 error(일관성 높음, `Relate` **선택지**: (a) `Slot`/`PreRef`와 같이 즉시 error(일관성 높음, `Relate`
하나로 Ref→현재 자리 추적), (b) UB로 두고 문서화만(현상 유지 — 하나로 Ref→현재 자리 추적), (b) UB로 두고 문서화만(현상 유지 —
단 증상이 "조용한 값 소실"이라 다른 UB보다 나쁨), (c) 마지막 쓰기 승리를 단 증상이 "조용한 값 소실"이라 다른 UB보다 나쁨), (c) 마지막 쓰기 승리를
정식 동작으로 인정(비권장, `Ref`의 "확정된 값 박스" 의미와 충돌). 정식 동작으로 인정(비권장, `Ref`의 "확정된 값 박스" 의미와 충돌).
### 0-A. `hintValue` 폐기 → process 하강 중 핸들러 비교 (2026-08-13 여섯 번째 세션, **Attribute 건 외 확정**)
**검토 결과 사용자 지적이 맞음 — 현행 `hintValue`엔 실제 결함이 있음.**
힌트가 "그 자리에 곧 디스패치될 raw 값"이라 `None` 센티널이나 `State`/
`Tween` 같은 래퍼가 그대로 넘어갈 수 있고, 그러면 말단 핸들러의
`isTag(hint)` 가드가 거짓이 되어 **깜빡임/재생성 방지가 조용히 꺼짐**
(정확성은 유지돼서 지금까지 안 드러났음). 상세 재현·분석·제안은
`research/dispatch-redispatch-diff-plan.md`.
**후속 라운드에서 모델은 거의 확정됨** — 래핑 핸들러가 `retractFrom`
선행 호출하는 걸 폐기하고, `Dispatch.process` 안에서 **핸들러를 먼저
비교**해 (같으면 그 자리 클로저에 새 값을 넘기고 자기 `process` 재호출,
다르면 그 자리부터 아래를 전량 철거). 이걸로 (a) 힌트의 타입이
구조적으로 보장되고(같은 핸들러일 때만 값이 넘어가므로), (b) 깊은 체인의
힌트 유실도 사라지며(각 레벨이 자기 재프로세스에서 자기 힌트를 받음),
(c) `oldValue`를 따로 넘기자던 보완안은 불필요해짐(사용자 지적:
"클로저라 이미 본인이 알지 않아요?" — 맞음, `chains`에 추가로 저장할 건
비교용 `handler` 하나뿐), (d) `HandlerChanged` 마커도 불필요(핸들러가
바뀌었다는 건 retractor가 `nil` 힌트로 불린다는 사실로 이미 표현됨).
**남은 열린 항목은 Attribute 이름 소유권 하나뿐 — 위 0-Z로 분리해
최우선 배치**(사용자가 다음 세션에 직접 스케치하며 심층 분석하기로).
그 하나 외에는 이 항목에 결정할 게 없음.
**실행 규모**: `base/``bind-system-plan.md`/`tag-plan.md`/`slot-plan.md`/
`attribute-plan.md`/`ref-plan.md` 의사코드 재작성 + `architecture.md`
소스트리 서술과 `ROADMAP.md` M2/M4/M6/M10 체크리스트 — 0-Z 하나만 정해지면
한 번에 옮기면 됨(어디를 어떻게 고칠지는
`research/dispatch-redispatch-diff-plan.md` 6절에 파일별로 적어둠 — 뒤
셋은 2026-08-13 7차/10차 감사에서 그 목록에 빠져 있던 걸 발견해 추가).
**그때까지 `base/`의 현행 `hintValue` 서술이 유효** — 아직 안 옮겼다는 걸
잊고 base만 읽으면 옛 모델로 구현하게 되니 주의.
### 0-B. `dispose(any)` — 시그니처/범위 (2026-08-13 여섯 번째 세션 신설, 사용자 제안) ### 0-B. `dispose(any)` — 시그니처/범위 (2026-08-13 여섯 번째 세션 신설, 사용자 제안)
`State<Slot>` 교체를 파괴가 아니라 **언마운트**로 확정하면서(`state<Frame>`와 `State<Slot>` 교체를 파괴가 아니라 **언마운트**로 확정하면서(`state<Frame>`와
@ -201,6 +128,13 @@ Attribute가 직접 해야 함.**
질문이라 `is`보다 `can` 계열 접두를 유지하는 쪽이 낫다는 방향으로 사용자가 질문이라 `is`보다 `can` 계열 접두를 유지하는 쪽이 낫다는 방향으로 사용자가
기욺 — 여전히 미확정, 다음에 `can`으로 시작하는 구체 대안(예: `canRun`)을 기욺 — 여전히 미확정, 다음에 `can`으로 시작하는 구체 대안(예: `canRun`)을
같이 검토할 것. 같이 검토할 것.
- **클로저 인자 이름 `hintValue`(3순위, 사소함, 2026-08-13 열네 번째
세션 신설)**: 하강 diff 재디스패치에서 이 인자는 더 이상 "힌트"가
아니라 **`nil`이거나 같은 핸들러가 곧 처리할 새 값**임이 계약으로
보장됨(`base/dispatch-core-plan.md`) — 이름이 옛 모델의 잔재라
`nextValue`류가 더 정확함. 코퍼스에 이미 널리 쓰인 이름이라 이번엔
안 바꾸고 대기열에만 올림(의사코드는 새로 쓰는 자리부터 `nextValue`
쓰기 시작했음).
- **`Brand`(3순위, 사소함, 2026-08-07 여덟 번째 세션 추가)**: 런타임 - **`Brand`(3순위, 사소함, 2026-08-07 여덟 번째 세션 추가)**: 런타임
nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`, `isState` nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`, `isState`
10종 branded 타입 전부로 일반화) — `brand-plan.md``Brand` 10종 branded 타입 전부로 일반화) — `brand-plan.md``Brand`
@ -251,12 +185,19 @@ Attribute가 직접 해야 함.**
규칙에 그대로 맞아 포함 근거는 있음. 상세는 `research/operator-sugar-plan.md`. 규칙에 그대로 맞아 포함 근거는 있음. 상세는 `research/operator-sugar-plan.md`.
구현 자체는 맨 마지막 우선순위(순수 슈가, 없어도 무방) — 여전함. 구현 자체는 맨 마지막 우선순위(순수 슈가, 없어도 무방) — 여전함.
- **중첩 State 평탄화 `State<State<T>>``State<T>`(2026-08-13 여섯 번째 - **중첩 State 평탄화 `State<State<T>>``State<T>`(2026-08-13 여섯 번째
세션 신설, 백로그)** — 인덱스 기반 `Dispatch` 재설계로 `State<State<T>>` 세션 신설, 백로그)** — **[근거 축소, 열네 번째 세션]** 원래 이 항목의
UB에서 정상 지원 대상이 됐지만, 깊이가 늘수록 `retractFrom`의 힌트가 주 근거는 "깊은 체인에선 힌트가 `nil`로 전달돼 깜빡임 방지가 꺼진다"는
더 깊은 인덱스엔 `nil`로 전달돼 `Tag`/`Ref`/`Slot` 등의 힌트 기반 실제 기능 손실이었는데, **하강 diff 재디스패치로 각 레벨이 자기 값을
최적화(깜빡임 방지)가 무력화되는 실제 기능 손실이 있음 — 값 층에서 받게 되면서 그 손실 자체가 없어졌음**(`base/dispatch-core-plan.md`).
평탄화하는 `state:Flatten()`류 콤비네이터 아이디어는 나왔으나 착수 안 남은 근거는 편의성과 Slot offset이 밀리고 당겨지는 케이스뿐이라
함. 상세는 `research/operator-sugar-plan.md` 마지막 절. 우선순위가 더 내려감 — `state:Flatten()`류 콤비네이터 아이디어는
그대로 백로그. 상세는 `research/operator-sugar-plan.md` 마지막 절.
- **[신설, 2026-08-13 열네 번째 세션] `Attribute.Merged`의 이름 중복** —
두 Store가 같은 이름을 가지면 지금은 `:NameMap()` 평탄화 단계에서
조용히 하나가 이김(dispatch 이전이라 이름 claim이 못 잡는 자리).
합성 시점 1회 체크로 error를 내는 게 이 문서 다른 결정들과 결이
같지만, "Merged는 뒤가 이긴다"를 의도된 override로 볼 여지도 있어
사용자 확인 필요 — `base/attribute-plan.md` "열린 질문" 절.
- `research/existing-instance-bind-plan.md` — 스코프 논의만 필요, 구현 - `research/existing-instance-bind-plan.md` — 스코프 논의만 필요, 구현
착수를 막지 않음. 착수를 막지 않음.
- **`quad-debug` 세부 API 이름** — `research/debug-tooling-plan.md` 참고. - **`quad-debug` 세부 API 이름** — `research/debug-tooling-plan.md` 참고.

View file

@ -96,7 +96,7 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부
- 심화: 정적 merge vs 런타임 pluggable 기각 이유(CSS cascade) / immutable+clone 체이닝 이유(형제 오염 방지) / getter 미채택 이유 / `__index` 런타임 구현 통찰 / Modifier가 핸들러 계층을 모르는 이유 / base/roblox 패키지 경계(Dispatch/Slot vs Handlers/Slot) / Slot 단일 마운트 소유권이 v1/Fusion/Vide 대비 개선인 이유 / ~~retract=폐기 확정 히스토리(portal 검토 후 기각)~~ **[2026-08-13 정정] 위와 같은 이유로 역전 — 이 항목은 "왜 한때 destroy+no-portal로 결정했었는가"라는 히스토리 소재로만 유효, 현재 결론 아님** / **왜 `Apply`가 기본이고 `Overridden`는 최적화 특수 케이스인가**(계산 의존성 있는 조합 vs 독립적 재사용 가능 조각의 병합 — 2026-08-07 다섯 번째 세션, `modifier-plan.md` 9번) / 왜 `Apply`가 clone 대신 mutate하지 않는가(형제 오염 방지가 개별 clone 비용 절감보다 우선) - 심화: 정적 merge vs 런타임 pluggable 기각 이유(CSS cascade) / immutable+clone 체이닝 이유(형제 오염 방지) / getter 미채택 이유 / `__index` 런타임 구현 통찰 / Modifier가 핸들러 계층을 모르는 이유 / base/roblox 패키지 경계(Dispatch/Slot vs Handlers/Slot) / Slot 단일 마운트 소유권이 v1/Fusion/Vide 대비 개선인 이유 / ~~retract=폐기 확정 히스토리(portal 검토 후 기각)~~ **[2026-08-13 정정] 위와 같은 이유로 역전 — 이 항목은 "왜 한때 destroy+no-portal로 결정했었는가"라는 히스토리 소재로만 유효, 현재 결론 아님** / **왜 `Apply`가 기본이고 `Overridden`는 최적화 특수 케이스인가**(계산 의존성 있는 조합 vs 독립적 재사용 가능 조각의 병합 — 2026-08-07 다섯 번째 세션, `modifier-plan.md` 9번) / 왜 `Apply`가 clone 대신 mutate하지 않는가(형제 오염 방지가 개별 clone 비용 절감보다 우선)
- 열린 질문(문서화 보류): ~~여러 Slot이 형제로 섞일 때 순서 보장~~ **[해소됨, - 열린 질문(문서화 보류): ~~여러 Slot이 형제로 섞일 때 순서 보장~~ **[해소됨,
2026-08-09 여섯 번째 세션]** Length/Offset 누적합으로 확정, 심화 목록에 2026-08-09 여섯 번째 세션]** Length/Offset 누적합으로 확정, 심화 목록에
추가 필요(`base/bind-system-plan.md` "Length/Offset" 절). 추가 필요(`base/dispatch-core-plan.md` "Length/Offset" 절).
**[2026-08-09 추가]** `Slot:List``prev`/`userdata` 재사용 최적화를 **[2026-08-09 추가]** `Slot:List``prev`/`userdata` 재사용 최적화를
getting-started에서 "항상 파괴 후 재생성" 단순 버전만 가르치고 나중에 getting-started에서 "항상 파괴 후 재생성" 단순 버전만 가르치고 나중에
최적화 단계에서 별도로 알려줄지, 아니면 Slot이 학습 순서상 core loop 최적화 단계에서 별도로 알려줄지, 아니면 Slot이 학습 순서상 core loop
@ -250,7 +250,7 @@ additional-primitives-plan.md`의 "문서화 백로그" 절이 원자료)**:
- **[해소됨]** Slot 형제 순서 보장 — `Dispatch.setLength`/ - **[해소됨]** Slot 형제 순서 보장 — `Dispatch.setLength`/
`setOffsetSource`(Length/Offset)로 2026-08-09 여섯 번째 세션에 확정, `setOffsetSource`(Length/Offset)로 2026-08-09 여섯 번째 세션에 확정,
`bind-system-plan.md` "Length/Offset" 절 참고. `dispatch-core-plan.md` "Length/Offset" 절 참고.
- **[해소됨, 2026-08-13 정정]** Tween 오버라이드/옵션 값 모양 — - **[해소됨, 2026-08-13 정정]** Tween 오버라이드/옵션 값 모양 —
2026-08-12 첫 번째 세션에 `Info: TweenInfo?`+편의 필드 폴백, 2026-08-12 첫 번째 세션에 `Info: TweenInfo?`+편의 필드 폴백,
override 정책은 `Tween.Cancel`(기본)/`Tween.Finish` 2값으로 확정, override 정책은 `Tween.Cancel`(기본)/`Tween.Finish` 2값으로 확정,

View file

@ -306,15 +306,17 @@ Attribute는 이미 "겹치면 error"로 소유 코드가 명확히 갈리는
"Dispatch 체인" 절). 하지만 **동작한다고 해서 권장 방향인 건 아님** "Dispatch 체인" 절). 하지만 **동작한다고 해서 권장 방향인 건 아님**
(사용자 판단: "UB는 아니지만 우리가 원치 않는 방향인건 맞습니다"). (사용자 판단: "UB는 아니지만 우리가 원치 않는 방향인건 맞습니다").
**왜 원치 않는가 — 단순 취향이 아니라 실제 기능 손실이 있음**: **[근거 축소, 2026-08-13 열네 번째 세션] 원래 이 항목의 주 근거였던
`Dispatch.retractFrom(inst,k,index,v)`는 힌트 `v`를 정확히 `index` "깊은 체인에선 힌트가 사라져 깜빡임 방지가 꺼진다"는 손실은 없어졌음.**
자리에만 넘기고 더 깊은 인덱스엔 `nil`을 넘김. 그래서 `State<Tag>` 당시 서술: 옛 `Dispatch.retractFrom(inst,k,index,v)`가 힌트 `v`를 정확히
(한 겹, StoreBind@1 → TagHandler@2)에서는 재발행 시 TagHandler가 `index` 자리에만 넘기고 더 깊은 인덱스엔 `nil`을 넘겼기 때문에
`hintValue=newTag`**확실히** 받아 깜빡임 방지가 동작하지만, `State<State<Tag>>`에서 바깥이 재발행하면 TagHandler가 `nil`을 받아
`State<State<Tag>>`(StoreBind@1 → StoreBind@2 → TagHandler@3)에서 `RemoveTag`→`AddTag` 왕복이 일어났음. **하강 diff 재디스패치**에선 각
**바깥** store가 재발행하면 TagHandler는 `nil`을 받아 `RemoveTag` 레벨이 자기 재프로세스에서 자기 값을 받으므로 깊이와 무관하게 진짜
`AddTag` 왕복이 실제로 일어남. 깊이가 늘수록 힌트 기반 최적화 `Tag` 객체가 전달됨(`base/dispatch-core-plan.md` "Dispatch 체인" 절).
(`Tag`의 `Contains`, `Ref`/`Slot`의 identity 비교)가 전부 무력화됨. 남은 근거는 (a) 편의성/의도 표현, (b) `state<state<Frame>>`류에서 Slot
offset이 밀리고 당겨지는 케이스(이건 이미 "그냥 확인된 것"으로 수용)
정도라 **우선순위가 더 내려감**.
**아이디어(착수 안 함)**: 중첩을 Dispatch 층에서 감내하는 대신, 값 **아이디어(착수 안 함)**: 중첩을 Dispatch 층에서 감내하는 대신, 값
층에서 **평탄화하는 콤비네이터**를 제공 — `State<State<T>>`를 받아 층에서 **평탄화하는 콤비네이터**를 제공 — `State<State<T>>`를 받아

View file

@ -48,7 +48,7 @@ PropertyHandler가 직접 판별. 상세는 `base/tween-plan.md`(전면
재작성), 구 모델은 `archive/tween-special-bind-key-reversed.md`. 아래는 재작성), 구 모델은 `archive/tween-special-bind-key-reversed.md`. 아래는
원래 발견 당시 기록. 원래 발견 당시 기록.
**위치**: `base/bind-system-plan.md` "확정된 디스패치 모델" 절 67-79행 — **위치**: `base/dispatch-core-plan.md` "확정된 디스패치 모델" 절 67-79행 —
"Tween의 store-bind 핸들러는 **`k`는 무엇이든 받고 `v`가 Store인 경우를 "Tween의 store-bind 핸들러는 **`k`는 무엇이든 받고 `v`가 Store인 경우를
잡아내는, 우선순위가 매우 높은 핸들러**"; `architecture.md` 소스트리엔 이 잡아내는, 우선순위가 매우 높은 핸들러**"; `architecture.md` 소스트리엔 이
역할을 하는 quad-roblox 파일이 `Handlers/Tween.luau` 하나뿐(별도 범용 역할을 하는 quad-roblox 파일이 `Handlers/Tween.luau` 하나뿐(별도 범용
@ -79,10 +79,11 @@ store.color }`처럼 애니메이션 없이 그냥 반응형으로 값만 바뀌
### 1-2. retract 시 "이전에 실제로 매치됐던 핸들러"를 누가 추적하는지 불명 — [해소됨, 2026-08-08 세 번째 세션] ### 1-2. retract 시 "이전에 실제로 매치됐던 핸들러"를 누가 추적하는지 불명 — [해소됨, 2026-08-08 세 번째 세션]
**해소**: `Dispatch``(inst,k)`별 핸들러 체인(순서 있는 배열, `chains`)을 **해소**: `Dispatch``(inst,k)`별 핸들러 체인(순서 있는 배열, `chains`)을
직접 소유하고, `Dispatch.retractFrom(inst,k,index,v)`가 꼬리부터 `index` 직접 소유하고, `Dispatch.retractFrom(inst,k,index)`가 꼬리부터 `index`
까지 정리해주는 걸로 확정(2026-08-08 확정 당시 이름은 `retractUnder`이고 까지 정리해주는 걸로 확정(2026-08-08 확정 당시 이름은 `retractUnder`이고
체인이 핸들러 배열이었음 — 2026-08-13 다섯 번째 세션에 인덱스 기반으로 체인이 핸들러 배열이었음 — 2026-08-13 다섯 번째 세션에 인덱스 기반으로
재설계되며 개명, 결론 자체는 유지) — 아래 원래 제안(`Dispatch/StoreBind.luau`가 재설계되며 개명, 같은 날 열네 번째 세션에 힌트 인자가 빠져 3-인자가 됨,
결론 자체는 유지) — 아래 원래 제안(`Dispatch/StoreBind.luau`가
"마지막 선택된 핸들러"를 직접 들고 있는 방식)은 재귀/래핑 핸들러가 "마지막 선택된 핸들러"를 직접 들고 있는 방식)은 재귀/래핑 핸들러가
여러 단계(A→B→C)로 겹칠 때 자기 자신의 상태와 위임한 핸들러의 상태가 여러 단계(A→B→C)로 겹칠 때 자기 자신의 상태와 위임한 핸들러의 상태가
슬롯 하나를 두고 충돌하는 문제가 있어 기각되고, 대신 Dispatch 자신이 슬롯 하나를 두고 충돌하는 문제가 있어 기각되고, 대신 Dispatch 자신이
@ -90,7 +91,7 @@ store.color }`처럼 애니메이션 없이 그냥 반응형으로 값만 바뀌
bind-system-plan.md` "Dispatch 체인" 절, `ROADMAP.md` M2/M4. 아래는 bind-system-plan.md` "Dispatch 체인" 절, `ROADMAP.md` M2/M4. 아래는
원래 발견 당시 기록. 원래 발견 당시 기록.
**위치**: `base/bind-system-plan.md` "확정된 디스패치 모델" 절 90-91행 — **위치**: `base/dispatch-core-plan.md` "확정된 디스패치 모델" 절 90-91행 —
"store bind가 새 값으로 넘어갈 때 이전 핸들러의 `retract(inst, k, v)` "store bind가 새 값으로 넘어갈 때 이전 핸들러의 `retract(inst, k, v)`
한 번 호출해주면 됨." 한 번 호출해주면 됨."
@ -115,10 +116,10 @@ bind-system-plan.md` "Dispatch 체인" 절, `ROADMAP.md` M2/M4. 아래는
매치 실패는 조용한 무시 없이 즉시 `error`(브랜드+`typeof` 출력, provider 매치 실패는 조용한 무시 없이 즉시 `error`(브랜드+`typeof` 출력, provider
초기화 확인 안내)로 확정. 핸들러 등록 시점 동률 감지 print 경고와 초기화 확인 안내)로 확정. 핸들러 등록 시점 동률 감지 print 경고와
`Dispatch.listHandlers()`류 전체 목록 조회 함수도 M2 기본 기능으로 `Dispatch.listHandlers()`류 전체 목록 조회 함수도 M2 기본 기능으로
확정 — 상세는 `base/bind-system-plan.md` "우선순위 동률/매치 실패 처리" 확정 — 상세는 `base/dispatch-core-plan.md` "우선순위 동률/매치 실패 처리"
절. 아래는 원래 발견 당시 기록. 절. 아래는 원래 발견 당시 기록.
**위치**: `base/bind-system-plan.md` "핸들러 계약" 절 — "디스패치는 등록된 **위치**: `base/dispatch-core-plan.md` "핸들러 계약" 절 — "디스패치는 등록된
핸들러를 우선순위 순으로 스캔하며 `isHandlable`을 호출, 첫 매치가 처리." 핸들러를 우선순위 순으로 스캔하며 `isHandlable`을 호출, 첫 매치가 처리."
**문제**: (a) 두 핸들러가 같은 `priority` 값을 가질 때 어느 쪽이 우선인지 **문제**: (a) 두 핸들러가 같은 `priority` 값을 가질 때 어느 쪽이 우선인지

View file

@ -0,0 +1,198 @@
# 2026-08-13 열네 번째 세션 — 0-Z(`Attribute:GetKey`) 확정, 하강 diff 재디스패치 전면 반영, Tag/Attribute를 quad-base로 재배치
**한 줄 요약**: 사용자가 `Attribute:GetKey(name)` 아이디어를 제시하며 0-Z를
다시 열었고, 트레이싱으로 검증한 뒤 **그룹 전용 키 + 이름 claim**으로
확정 → 그 김에 **0-A(하강 diff 재디스패치)까지 base 전면 반영 + 디스패치
코어 분리(`dispatch-core-plan.md` 신설) + Tag/Attribute 패키지 재배치**를
한 패스로 처리하고 커밋. **M0 착수를 막는 결정이 이제 하나도 없음.**
---
## 1. 시작 — 사용자 문제 제기
> "Attribute 에 대해서 말이지, 우린 이전의 결정을 다시 돌아봐야할 필요가
> 있는듯. `Attribute:GetKey(name)` 구현으로써 '조용한 문제 없음' 을
> 해결할 수 있을것으로 보이는데. 한번 확인해볼래?"
`question.md` 0-Z(Attribute 이름 소유권)는 여섯 번째 세션에 사용자가
"다음 세션에 직접 물리적으로 스케치하며 심층 분석"으로 명시 이관해둔
최우선 항목이었고, 그때의 잠정 방향은 **(a) 이름별 claimant `Relate`
Attribute 안에 두기**였음.
## 2. 검증 — 문제를 두 갈래로 분해
| | 증상 | 하강 diff 모델에서 |
|---|---|---|
| **C1 그룹 A ↔ 그룹 B** | 같은 이름 | 둘 다 `StoreBind` → "같은 핸들러"로 조용히 인계 + A 클로저가 B 걸 철거(교차 오염) |
| **C2 그룹 ↔ 직접 쓰기** | 같은 이름 | 핸들러가 다름 → `retractFrom` 후 조용히 교체 |
| **C3 같은 그룹 객체 두 자리** | 같은 이름 | 키가 같아 수렴 → C1과 동일 |
**핵심 발견 — 권고안 (a)는 C2를 못 잡음.** 직접 쓰기는 그룹 코드를 아예
안 지나가서 그룹 쪽 레지스트리에 등록될 자리가 없고, 두 경로가 실제로
만나는 유일한 지점인 `AttributeKeyHandler`에서는 공개 캐시 키를 쓰는 한
`k`가 **같은 객체**라 소유자를 구분할 방법이 원천적으로 없음. 옛 모델에선
Dispatch의 점유 체크가 이걸 대신 잡아줬는데, 하강 diff에선 점유가 정상
상태라 그 체크 자체가 성립하지 않음.
**그래서 `GetKey`가 필요한 이유가 분명해짐**: 전용 키를 쓰면
- 체인이 소유자별로 갈려 **교차 오염이 구조적으로 불가능**해지고,
- 소유권이 **키 identity**로 표현되므로 말단 핸들러가 "이 이름을 지금
누가 잡고 있나"를 단방향 맵 하나로 판정할 수 있음(그룹인지 직접
쓰기인지 알 필요조차 없음).
즉 정확히는 **`GetKey`(교차 오염 제거) + 이름 claim(감지)** 두 조각이
한 세트. `GetKey`만으로는 두 소유자가 각자 `setAttribute`를 쏘는
flip-flop이 조용히 남음.
**순서 검증**(하강 diff에서 "해제 → 재클레임"이 항상 보장되는가):
같은 핸들러 재프로세스는 `slot.retractor(v)``h.process(...)`,
핸들러 교체는 `retractFrom`(꼬리부터) → `process` — 어느 경로든 옛 claim
반납이 먼저. `5 → None → 5`처럼 체인 깊이가 오가는 경우도 인덱스가
바뀌는 자리에서 `retractFrom`이 먼저 돌아 동일. `State<Modifier>`가 이미
차단돼 있고 배열 위치는 정적이라, 위치를 가로지르는 "새 소유자 먼저
claim" 경로도 없음.
**남은 구멍으로 보고한 것**: (1) `GetKey`를 공개 API로 내면 사용자가 그
키를 다른 자리에 다시 놓아 수렴시킬 수 있고 그건 claim(키 identity 기준)
으로도 못 잡음 → **사용자가 "비공개로 안 내도 될듯"으로 확정**, (2)
base/roblox 패키지 경계, (3) `Attribute.Merged`의 이름 중복이 dispatch
이전 단계라 claim이 못 잡음(→ 새 열린 질문으로 등록).
## 3. 사용자의 두 번째 제기 — 패키지 배치가 이상하다
> "Attribute 는 왜 quad-base 인지 모르겠습니다. 사실, Attribute 랑 Tag
> 모두 다른 곳에서도 사용할 수 있거나 없거나 해서, 그냥 quad-base 에 있고,
> set 메커니즘(최종 inst:Set... 와 Tag 제거 추가) 부분만 quad-roblox 에
> 작성하는게 좋을수도 있어보여요. 왜냐하면 웹에도 className, data-
> attribute 가 있습니다. (...) 안 그럼 다시 재구현 될 부분이 너무 많은것
> 같은 느낌."
동의. 당시 배치는 **알고리즘 전체가 quad-roblox, 값 타입만 base**였는데
실제로 엔진에 종속된 건 마지막 한 줄뿐:
| 층 | 내용 | 엔진 종속? |
|---|---|---|
| 값 타입/API | `Tag(...)`, `Attribute(...)`, `AttributeKey(name)`+weak 캐시 | ✗ |
| 알고리즘 | Tag 참조 카운트, 그룹 위임, **이름 claim**, `None` 처리 | ✗ |
| 실행 | `AddTag`/`RemoveTag` / `SetAttribute` | **✓ 3줄** |
`architecture.md`가 이미 *"pluggable 디스패치 엔진 자체도 인터페이스로
base가 소유 — 엔진마다 큰 구현을 중복하지 않기 위함"*이라고 못박아뒀는데
Tag/Attribute만 그 원칙 밖에 있었음. 선례도 있음 — `LifetimeHandle`
base엔 인터페이스, quad-roblox엔 gcconn 구현으로 갈려 있고 주입은
`RobloxFactory(BaseModule)` 뮤테이션. **`addTag`/`removeTag`/`setAttribute`는
그 목록에 3개를 더하는 것뿐, 새 메커니즘이 아님.**
세부 합의:
- **`addTag(inst, names: {string})`** — vararg가 아니라 테이블. 근거는
열다섯 번째 세션에 `Tag:Added`가 vararg → `string | {string}`으로
되돌아갔던 것과 같음(`table.unpack`이 tail 위치에서만 완전히 펼쳐짐 +
대량 이름에서 unpack 한계). 웹 `className` 배치 갱신 요구도 테이블로
충족됨. **사용자 동의**.
- **타입 패밀리는 갈림** — 제네릭 `AttributeKey<<T>>`와 스칼라 3종은
base, `Color3Attribute`류는 백엔드. 사용자: "그건 D/DI 쪽에서 각자
구현임. 타입 쪽은 그쪽에서 처리하면 되는것 같고".
- **미주입 백엔드 실패 모드** → 사용자가 더 나은 안을 제시:
> "그렇다면 failback... 이름의 아주 낮은 우선순위의 요소,
> PRIORITY_FAILBACK 정도를 잡아두는게 좋겠네요. 그건 base 에서
> 주입해버려도 되고, 위에서 처리되면 상관 없게 잘 처리되니까요."
**`HANDLER_PRIORITY_FALLBACK`** 신설(기존 `HANDLER_PRIORITY_*` 패밀리에
추가, 철자는 영어 표준형 FALLBACK으로 정규화). base 제공 핸들러는 이
밴드에 등록하므로 백엔드가 평범한 우선순위로 자기 핸들러를 등록하면
언제나 이김 — 비활성화나 등록 순서 조정이 필요 없음. op 자체가 없는
백엔드에서는 base 스텁이 명확한 에러를 냄.
## 4. 반영 — 한 패스로
사용자 지시: *"모두 제 생각과 같으니까, 그렇게 처리해도 좋아요. 다만
이전처럼 많은 루프를 돌며 문서를 안 고치게, 유의해가며 정리해줘요.
(그걸 위해서 상위 모델로 올리기도 했고.) 처리 후 핸드오버 할거예요.
clear 하게 준비해두고, 커밋하세요."*
### 4-1. `bind-system-plan.md` 2단계 분할 + 재작성
9차 세션이 *"디스패치 코어는 0-Z 확정 시 어차피 전면 재작성 대상이라,
재작성하는 그 패스에서 파일을 가르는 게 총 변경량·실수 위험이 모두
작음"*이라며 의도적으로 미뤄둔 계획을 그대로 실행 — `문제`/`핸들러 계약`/
`확정된 디스패치 모델`/`None 센티널`/`Dispatch는 프리미티브가 아니다`/
`Dispatch 체인`/`Handler 작성 체크리스트`/`Length·Offset`/`store 바인드는
래핑` 블록(1074줄)을 **`base/dispatch-core-plan.md`**로 옮기고 새 모델로
재작성. `bind-system-plan.md`는 2263 → 1219줄(반응형 코어 + 인체공학).
인바운드 참조는 `doc-check.py` 출력을 그대로 입력으로 삼아 기계적으로
고침(파일별 라인 지정 치환) — 15개 파일 30여 곳.
### 4-2. 하강 diff 모델의 실제 내용
```lua
-- chains[inst][k][index] = { handler = h, retractor = fn }
function Dispatch.process(inst, k, v, index)
local list = <확보 + chains 등록> -- 순서 규칙 그대로(h.process 前)
local slot, h = list[index], Dispatch.getHandler(inst, k, v)
if slot ~= nil and slot.handler == h then -- (A) 같은 핸들러
slot.retractor(v) -- v는 isHandlable(v) 보장됨
slot.retractor = h.process(inst, k, v, index)
else -- (B) 다른 핸들러/빈 자리
Dispatch.retractFrom(inst, k, index)
list[index] = { handler = h, retractor = NOOP }
list[index] = { handler = h, retractor = h.process(inst, k, v, index) }
end
end
```
**이 세션에서 새로 도출한 귀결 — `retractFrom`이 3-인자가 됨.** 값을
넘기는 경로가 (A) 분기 하나로 통일되면서 외부가 힌트를 만들어 넣을 자리
자체가 사라짐 → 옛 결함(래퍼/센티널이 힌트로 새는 것)이 **구조적으로
재발 불가**. 설계안 원문(6절)엔 4-인자 `retractFrom(inst,k,index,nil)`
그대로 남아 있었는데, 모델의 필연적 귀결이라 판단해 3-인자로 정리하고
핸드오버에 명시.
같이 폐기/갱신된 것:
- `isX(hintValue)` 방어 가드 **일반 규칙 폐지**(타입이 계약으로 보장됨)
- "`hintValue`는 직속 1단계에만, 깊은 인덱스는 `nil`" 캐비엇 **삭제**
각 레벨이 자기 값을 받으므로 `State<State<Tag>>`에서도 깜빡임 방지 유효
- Dispatch의 **점유 체크(소유권 감지) 폐지** → 필요한 도메인(Attribute)이
직접
- Handler 작성 체크리스트 2·3번 전면 교체, 8번(중간 노드는 `inst`
손대지 않는다 + 항상 재위임) 신설
### 4-3. 반영 대상 문서 (배너 7개 + 인덱스 레이어)
`base/`: `dispatch-core-plan.md`(신설) / `bind-system-plan.md` /
`attribute-plan.md` / `tag-plan.md` / `slot-plan.md` / `ref-plan.md` /
`architecture.md`(소스 트리 포함) / `module-lifecycle-plan.md`(주입 op
목록) / `onchange-plan.md`(AttributeKey 위치 정정) / `relate-plan.md`
문구.
루트: `ROADMAP.md`(M0/M2/M4/M6/M10 배너·체크리스트) / `CLAUDE.md` /
`HUMAN_TODO.md`.
인덱스/질문: `.claude/README.md`(행 5개 갱신 + 신설 2행) /
`question.md`(최우선 칸 비움) / `archive/question-resolved.md`(0-Z/0-A
해소 마킹).
아카이브: `research/dispatch-redispatch-diff-plan.md`
`archive/dispatch-hintvalue-model-reversed.md`(옛 모델 골자 + 폐기된
규칙 목록을 머리에 추가).
### 4-4. 스파이크 상태 갱신
`04`(체인/`retractFrom`)와 `19`(소유권/참조카운트)가 **옛 모델을 검증
중**이라 `done/``rewrite-required/`로 이동. "코드가 깨진" 게 아니라
"설계가 바뀐" 경우라 STATUS.md에 그 구분을 명시하고, 각각 무엇을
살리고 무엇을 바꿔야 하는지 적음(`04`의 `chains:SetStrong` 순서 음성
대조군은 새 모델에서도 유효 → 살릴 것).
## 5. 새로 연 것 / 남은 것
- **새 열린 질문 1개(사소)**: `Attribute.Merged`의 이름 중복 —
`:NameMap()` 평탄화가 dispatch 이전이라 이름 claim이 못 잡음.
error로 갈지 "뒤가 이긴다"를 의도된 override로 볼지 사용자 확인
대기(`question.md` 3번).
- **용어 대기열 1개**: 클로저 인자 이름 `hintValue` — 이제 "힌트"가
아니므로 `nextValue`류가 정확함. 코퍼스 전반에 퍼진 이름이라 이번엔
안 바꾸고 대기열에만 올림(새로 쓴 의사코드는 `nextValue` 사용).
- **0-W**(같은 `Ref` 객체 이중 배치)는 그대로 열림 — 0-Z가 닫히면서
형제 프리미티브 표에서 `Ref`만 유일하게 비게 됐고, Attribute가 간
"국소 레지스트리 + 즉시 error" 모양이 자연스러운 선택지라는 점을
질문 문서에 덧붙임.
- `doc-check.py` **ERROR 0** 유지 확인.

113
CLAUDE.md
View file

@ -158,58 +158,39 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
## 지금 할 일 (우선순위순) ## 지금 할 일 (우선순위순)
0. **⭐ 최우선 — `.claude/question.md` 0-Z 결정.** (`question.md`엔 0. **⭐ M0 착수를 막는 결정은 이제 없음 (2026-08-13 열네 번째 세션 기준).**
0-B `dispose(any)` 시그니처/범위도 열려 있지만, M6 구현 세부만 막을 뿐 `question.md`의 최우선 항목이 **전부 비었음**`0-Y`(`:Compute` lazy
M0 착수 자체는 안 막아서 이 최우선 항목과 급 다르게 취급 — 별도 항목 핸들 계약)는 13차 세션에, **`0-Z`(Attribute 이름 소유권)와 `0-A`(재디스패치
아님.) 하강 diff)는 14차 세션에 확정·`base/` 반영 완료**. 남은 `0-W`(같은 `Ref`
이중 배치)/`0-B`(`dispose` 시그니처)는 M0가 아니라 각각 M4/M6 구현 세부를
막을 뿐임.
> **[2026-08-13 열세 번째 세션] 여기 같이 있던 `0-Y`는 해소됨.** **M0 착수 전 반드시 읽을 것 — 이 두 개는 "결정"이 아니라 "구현 규약"이라
> `:Compute(fn)`의 lazy 핸들 계약은 **그대로 유지**로 확정. 44개 여전히 유효**:
> 스파이크 재실측 결과 진짜 원인이 콜백 계약이 아니라 **Luau 자체의 - **`base/typing-limits.md`**(0-Y의 산물) — 핵심은 "파생 State를 만드는
> 한계**(재귀 제네릭이 다른 타입 인자로 자기를 반환하면 타입 안전성이 자리마다 결과 타입을 명시 주석으로 바인딩" + 7번 설계 체크리스트.
> 에러 없이 조용히 사라짐)였고, 당시 "raw 값이면 완전 클린"이라던 재귀 제네릭이 자기를 다른 타입 인자로 반환하면 Luau가 타입 안전성을
> 1차 판정도 **뒤집혔음**(raw 값 계약도 똑같이 불안전). 사용자 정리: **에러 없이 조용히** 잃는 상위 한계라 quad 쪽에서 우회하지 않기로
> "quad가 타입을 비틀 일이 아니라 상위 Luau의 현 한계이고, RFC/이슈 확정(RFC `relax-recursive-type-restriction` 수혜 대기, 추적
> 수혜를 받을 때 해결될 일이라 당장 우리가 할 수 있는 바 없다." `luau-lang/luau#2380`). 실측 근거는 `audit/type-recursion-issue/`.
> **구현 시 지켜야 할 규약이 생겼으니 M0 착수 전 반드시 읽을 것: - **`base/dispatch-core-plan.md`**(0-A/0-Z의 산물, 14차 세션에
> `base/typing-limits.md`**(핵심은 "파생 State를 만드는 자리마다 `bind-system-plan.md`에서 분리 신설) — 재디스패치가 "철거 후 재구축"이
> 결과 타입을 명시 주석으로 바인딩" + 7번 설계 체크리스트). 실측 아니라 **하강 diff**임, `retractFrom`은 3-인자, 클로저 인자는
> 근거는 `audit/type-recursion-issue/`, 해소 전 원문은 `nil`이거나 같은 핸들러가 처리할 값(타입 보장), `HANDLER_PRIORITY_FALLBACK`,
> `archive/question-resolved.md`의 0-Y 절. "base가 소유하는 핸들러와 주입되는 엔진 op"(`addTag`/`removeTag`/
`setAttribute`). **Handler 작성 체크리스트 8개**를 새 핸들러 짜기 전에
훑을 것 — 지난 세션들에서 실제로 반복된 실수 목록임.
**0-Z: Attribute 이름 소유권 결정.** 해소 전 원문은 `archive/question-resolved.md`(0-Y/0-Z/0-A 절), 뒤집힌 옛
2026-08-13 여섯 번째 세션에 `Dispatch` 재디스패치 모델이 "하강 diff"로 재디스패치 모델 전문은 `archive/dispatch-hintvalue-model-reversed.md`.
다시 정리되면서(`research/dispatch-redispatch-diff-plan.md`), **그
모델에서 유일하게 안 풀린 것이 Attribute 그룹의 이름 소유권 충돌
감지**임. 사용자가 "이전 결정(이름별 claimant `Relate`)을 다시 가져오는
게 맞아 보이나, 다음 세션에 직접 물리적으로 스케치하며 심층 분석"으로
명시 이관 — 그전까지 아래 항목들보다 우선.
**핸드오버 시 반드시 알아야 할 것**:
- **`base/`의 현행 `hintValue` 서술은 아직 옛 모델(철거 선행)이다.**
새 모델은 `research/dispatch-redispatch-diff-plan.md`에만 있음 —
base만 읽고 구현하면 옛 모델로 짜게 됨. 0-Z가 정해지면 그 문서 6절의
파일별 반영 목록대로 **한 번에** 옮길 것 — 대상은 ⚠️ 배너를 달고
있는 **7개**(`bind-system-plan.md`/`tag-plan.md`/`slot-plan.md`/
`attribute-plan.md` + **`architecture.md`/`ROADMAP.md`** — 뒤 둘은
2026-08-13 7차 감사에서 6절 목록에 빠져 있던 걸 발견해 추가 +
**`ref-plan.md`** — 9차 세션 분할 때 "`Ref`의 retract" 절이 배너
없이 옮겨간 걸 이번 감사에서 발견해 추가),
반영 후 각 배너도 같이 제거.
- 반대로 **`base/slot-plan.md`의 "언마운트/`dispose`/해제 순서"는 이미
확정 반영됨**(재디스패치 모델과 독립적인 결정이라 먼저 들어감).
- 이 세션에서 같은 날 두 차례 급하게 쓴 의사코드가 각각 버그를 냈다는
사실 자체가 교훈 — 0-Z 반영도 서두르지 말고 손 트레이싱을 거칠 것
(`bind-system-plan.md` "Handler 작성 체크리스트" 절이 그 산물).
1. **구현 시작 — 루트 `ROADMAP.md`의 M0부터.** 설계 단계는 2026-08-04 로드맵 1. **구현 시작 — 루트 `ROADMAP.md`의 M0부터.** 설계 단계는 2026-08-04 로드맵
인수인계 라운드로 종료. `research/pre-implementation-audit.md` 우선순위1은 인수인계 라운드로 종료. `research/pre-implementation-audit.md` 우선순위1은
2026-08-12 열일곱 번째 세션에 마지막 넷(1-3/1-4/1-10/1-11)까지 전부 2026-08-12 열일곱 번째 세션에 마지막 넷(1-3/1-4/1-10/1-11)까지 전부
해소되어 **11개 전원 완료** — 이 항목(우선순위1) 기준 남은 유일한 해소되어 **11개 전원 완료**. **[14차 세션 기준] 0-Y/0-Z/0-A까지 전부
게이트는 아래였음(**[2026-08-13 4차 감사 정정] 위 0번 항목의 0-Y/0-Z가 해소돼 설계 게이트는 남아있지 않음** — 착수 전 읽을 것은 위 0번의 두
이후 같은 날 발견돼 실제로는 게이트가 하나 더 있었음 — "유일한"은 그 문서(`typing-limits.md`/`dispatch-core-plan.md`)뿐이고, 스파이크 상태는
발견 전 서술. **[13차 세션 재정정] 그중 0-Y는 해소됐고 지금 남은 아래 그대로:
게이트는 0-Z 하나**, 다만 0-Y가 남긴 구현 규약
`base/typing-limits.md`는 M0 착수 전에 읽어야 함**):
- **`.claude/luau-test/`(2026-08-09 신설, 2026-08-13 기준 20개) 스파이크 - **`.claude/luau-test/`(2026-08-09 신설, 2026-08-13 기준 20개) 스파이크
결과 — [2026-08-13 여섯 번째 세션에 첫 실측 완료, 대부분 닫힘].** 결과 — [2026-08-13 여섯 번째 세션에 첫 실측 완료, 대부분 닫힘].**
**상태의 소스는 `.claude/luau-test/STATUS.md`**(pass / 사람 결정 필요 / **상태의 소스는 `.claude/luau-test/STATUS.md`**(pass / 사람 결정 필요 /
@ -231,9 +212,11 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
`SyntaxError` / `types.*` 실제 API 불일치). (3) 그 외엔 그대로 M0 `SyntaxError` / `types.*` 실제 API 불일치). (3) 그 외엔 그대로 M0
실제 코드 작성에 재사용. 실제 코드 작성에 재사용.
- **[주의] 위 "남은 것은 셋뿐"은 여섯 번째 세션 기준** — 13차 세션에 - **[주의] 위 "남은 것은 셋뿐"은 여섯 번째 세션 기준** — 13차 세션에
`08``done/`으로 가며 `review-required/`가 비었음. **개수의 소스는 `08``done/`으로 가며 `review-required/`가 비었고, **14차 세션에
항상 `luau-test/STATUS.md`.** 지금 M0 착수를 막는 건 0-Z 하나이고, `04`가 하강 diff 재설계로 전제가 바뀌어 `rewrite-required/`로 갔음**.
0-Y가 남긴 규약(`base/typing-limits.md`)은 착수 전 필독. **개수의 소스는 항상 `luau-test/STATUS.md`.** 지금 M0 착수를 막는
설계 결정은 없고, 0-Y/0-A가 남긴 규약(`base/typing-limits.md`/
`base/dispatch-core-plan.md`)은 착수 전 필독.
- 참고로 `04`(인덱스 기반 재설계 반영)와 `19`의 B/C 섹션(폐기된 - 참고로 `04`(인덱스 기반 재설계 반영)와 `19`의 B/C 섹션(폐기된
`rawNew`+`owners`/3분기 `claimOwner` 검증하던 것)은 **둘 다 여섯 `rawNew`+`owners`/3분기 `claimOwner` 검증하던 것)은 **둘 다 여섯
번째 세션에 재작성 완료**되어 통과 상태 — 더 이상 대기 항목 아님. 번째 세션에 재작성 완료**되어 통과 상태 — 더 이상 대기 항목 아님.
@ -965,8 +948,9 @@ handoff용 저장이 불필요해짐 — `Relate`는 여러 위치/사이클을
무력화, `SlotHandler`의 claim 실패 시 이중 파괴, `Ref` dedup 무력화). 무력화, `SlotHandler`의 claim 실패 시 이중 파괴, `Ref` dedup 무력화).
이어 사용자 결정으로 **`State<Slot>` 교체를 파괴→언마운트로 전환**(포탈이 이어 사용자 결정으로 **`State<Slot>` 교체를 파괴→언마운트로 전환**(포탈이
그 귀결이 됨), **`dispose(value)`**(트리가 요구하면 파괴 거부·error) 신설, 그 귀결이 됨), **`dispose(value)`**(트리가 요구하면 파괴 거부·error) 신설,
**재디스패치를 "하강 diff"로 재설계**(`research/dispatch-redispatch-diff-plan.md`, **재디스패치를 "하강 diff"로 재설계**(당시 `research/`의 설계안 —
**base 미반영 — `question.md` 0-Z 하나 남음**). `ROADMAP.md`/base 계약 개수 **base 미반영, `question.md` 0-Z 하나 남음**이었고 14차 세션에 확정·반영 후
`archive/dispatch-hintvalue-model-reversed.md`로 이전). `ROADMAP.md`/base 계약 개수
모순/`luau-test` stale도 정리. 마지막으로 `luau` 바이너리가 생겨 **첫 실측** 모순/`luau-test` stale도 정리. 마지막으로 `luau` 바이너리가 생겨 **첫 실측**
— 런타임 12개 전원 통과, `04`가 위 버그를 음성 대조군으로 재현, `07` 보강으로 — 런타임 12개 전원 통과, `04`가 위 버그를 음성 대조군으로 재현, `07` 보강으로
GC-native 전제 확정, `18``Relate` 순환 경고 실증. 타입에선 `:Compute(fn)` GC-native 전제 확정, `18``Relate` 순환 경고 실증. 타입에선 `:Compute(fn)`
@ -1025,7 +1009,7 @@ Slot 언마운트 전환 미반영 6곳. 뒤집힌 "폐기, 옮기지 않음 + p
**1단계 분할**(2989→2263줄, `ref-plan.md`/`event-plan.md`/`brand-plan.md`로 **1단계 분할**(2989→2263줄, `ref-plan.md`/`event-plan.md`/`brand-plan.md`로
순수 이동) — 남은 디스패치·반응형 코어는 0-Z 반영 때 **어차피 전면 재작성** 순수 이동) — 남은 디스패치·반응형 코어는 0-Z 반영 때 **어차피 전면 재작성**
대상이라 그 패스에서 같이 가르는 게 총 변경량·위험이 작다고 판단해 의도적 대상이라 그 패스에서 같이 가르는 게 총 변경량·위험이 작다고 판단해 의도적
연기(`dispatch-redispatch-diff-plan.md` 6절에 지시). (3) `question.md` 연기(당시 재디스패치 설계안 6절에 지시 — 14차 세션에 실제로 그렇게 처리됨). (3) `question.md`
**사용자가 답할 것만**으로 축소(525→279줄, 해소분은 **사용자가 답할 것만**으로 축소(525→279줄, 해소분은
`archive/question-resolved.md`). (4) 재발 방지는 규율 문서가 아니라 `archive/question-resolved.md`). (4) 재발 방지는 규율 문서가 아니라
**검사기**로 — `.claude/tools/doc-check.py` 신설(깨진 파일/절 참조, 색인 **검사기**로 — `.claude/tools/doc-check.py` 신설(깨진 파일/절 참조, 색인
@ -1054,7 +1038,7 @@ CLAUDE.md "작업 방식"에 중대 변경 핸드오버 체크리스트 6단계
정독. 열 번째 세션이 이미 고친 것과 같은 종류의 stale이 두 곳 더 남아있던 정독. 열 번째 세션이 이미 고친 것과 같은 종류의 stale이 두 곳 더 남아있던
게 핵심 발견 — `question.md` 0-Z/0-A와 `HUMAN_TODO.md` 4번이 여전히 게 핵심 발견 — `question.md` 0-Z/0-A와 `HUMAN_TODO.md` 4번이 여전히
"6개 문서"(ref-plan.md 누락)로 서술 중이던 것을 "7개"로 정정(같은 정정이 "6개 문서"(ref-plan.md 누락)로 서술 중이던 것을 "7개"로 정정(같은 정정이
`dispatch-redispatch-diff-plan.md`/`CLAUDE.md`엔 이미 반영돼 있었으나 이 재디스패치 설계안/`CLAUDE.md`엔 이미 반영돼 있었으나 이
두 파일엔 안 퍼져 있었음). 별도로 `ROADMAP.md` 백로그의 두 파일엔 안 퍼져 있었음). 별도로 `ROADMAP.md` 백로그의
`objectListClass.__newIndex` 재현 테스트 항목이 세 번째 세션에 이미 `objectListClass.__newIndex` 재현 테스트 항목이 세 번째 세션에 이미
불필요로 해소됐는데 그 반영이 이 파일에만 안 퍼져 있던 것도 정정. base/ 불필요로 해소됐는데 그 반영이 이 파일에만 안 퍼져 있던 것도 정정. base/
@ -1093,3 +1077,26 @@ research/reference/luau-test/archive 전체는 정합성 문제 없음 확인
배너뿐 아니라 본문 표·문단·결론까지 전수 수정(체크리스트 2번 준수). 배너뿐 아니라 본문 표·문단·결론까지 전수 수정(체크리스트 2번 준수).
교훈 — **`luau-analyze` 진단 0건은 타입 해소를 뜻하지 않음**, 타입 교훈 — **`luau-analyze` 진단 0건은 타입 해소를 뜻하지 않음**, 타입
스파이크는 `--annotate` + 음성 대조군 필수. 스파이크는 `--annotate` + 음성 대조군 필수.
**2026-08-13 열네 번째 세션 — 0-Z(`Attribute:GetKey`) 확정, 하강 diff 재디스패치
전면 반영, Tag/Attribute를 quad-base로 재배치**
(`session/2026-08-13-14-attribute-getkey-dispatch-diff-reflected.md`)
사용자가 `Attribute:GetKey(name)`으로 0-Z를 다시 열었고, 트레이싱 결과
**권고안 (a)(그룹 안 claimant `Relate`)가 그룹↔직접 쓰기 충돌을 못 잡는다**는
게 드러나(두 경로가 만나는 말단 핸들러에서 공개 키는 같은 객체라 소유자
구분 불가) **그룹 전용 키(비공개 `GetKey`) + `AttributeKeyHandler`의 이름
claim**으로 확정. 이걸로 마지막 게이트가 열려 **0-A(하강 diff 재디스패치)까지
한 패스로 base 전면 반영** — 9차 세션이 미뤄뒀던 `bind-system-plan.md`
2단계 분할을 같이 수행해 디스패치 코어를 **`base/dispatch-core-plan.md`
분리·재작성**(선행 `retractFrom` 폐기, `chains` 슬롯에 `handler` 동거,
`retractFrom`이 **3-인자**로 축소, `isX(hintValue)` 가드 규칙 폐지, 깊은
체인 힌트 유실 캐비엇 삭제, 점유 체크 폐지). 사용자 제기로 **Tag/Attribute의
부기 알고리즘을 통째로 quad-base로 재배치**하고 백엔드는
`addTag`/`removeTag(inst,{string})`/`setAttribute(inst,name,v)` 3개 op만
주입(웹 `className`/`data-*` 대응 — 안 그러면 같은 참조카운트/소유권
알고리즘이 백엔드마다 복제됨), 그 실패 모드를 위해 **`HANDLER_PRIORITY_FALLBACK`**
신설(사용자 제안 — base 핸들러는 최하위 밴드, 백엔드가 덮어쓰면 언제나
이김). 옛 모델은 `archive/dispatch-hintvalue-model-reversed.md`로 이전,
스파이크 `04`/`19`는 옛 모델을 검증 중이라 `rewrite-required/`로 이동.
**M0 착수를 막는 결정이 이제 없음** — 새로 연 것은 사소한 둘뿐
(`Attribute.Merged` 이름 중복, `hintValue` 이름 재검토).

View file

@ -3,8 +3,8 @@
에이전트가 못 하거나(로컬 GUI 조작, 외부 계정/기기 필요) 사용자의 결정이 필요해서 에이전트가 못 하거나(로컬 GUI 조작, 외부 계정/기기 필요) 사용자의 결정이 필요해서
멈춰둔 것만 여기 모음. 설계 질문은 대체로 `.claude/question.md`에 따로 있고 디폴트를 멈춰둔 것만 여기 모음. 설계 질문은 대체로 `.claude/question.md`에 따로 있고 디폴트를
잡아둔 채 진행 중이라 급하지 않음 — **단 2026-08-13부터는 예외가 생겨 아래 4번에 잡아둔 채 진행 중이라 급하지 않음 — **단 2026-08-13부터는 예외가 생겨 아래 4번에
올렸음**(0-Z, M0 착수를 실제로 막고 있고 사용자가 직접 판단하겠다고 한 항목. 올렸었음**(0-Z) — **[2026-08-13 열네 번째 세션] 그 0-Z도 해소되어 지금은
같이 올렸던 0-Y는 같은 날 열세 번째 세션에 해소됨). 사람이 결정해야 M0가 열리는 항목이 없음**(0-Y는 열세 번째 세션에 해소).
## 1. Roblox Studio에 MCP로 연결 (테스트 자동화용) ## 1. Roblox Studio에 MCP로 연결 (테스트 자동화용)
@ -56,34 +56,27 @@ git.qwreey.moe에 제한된 계정 생성). 로컬 git 저장소는 이미 초
내용은 항상 `.claude/`에 자기 문서화(완료 표시, 다음 TODO 갱신)해서 다음 세션이나 내용은 항상 `.claude/`에 자기 문서화(완료 표시, 다음 TODO 갱신)해서 다음 세션이나
사람이 바로 이어받을 수 있게 할 것. 사람이 바로 이어받을 수 있게 할 것.
## 4. **[2026-08-13 신설, 막고 있음] `question.md` 0-Z 결정** ## 4. ~~`question.md` 0-Z 결정~~ **[해소됨, 2026-08-13 열네 번째 세션]**
**이건 위 3번과 성격이 다름 — 실제로 M0 구현 착수를 막고 있고, 사용자가 **더 이상 사람이 막고 있는 결정이 아님.** 사용자가 같은 세션에 직접
"직접 스케치하며 판단하겠다"고 명시 이관한 항목**이라 에이전트가 기본값으로 `Attribute:GetKey(name)` 방향을 제시했고, 트레이싱으로 검증한 뒤
밀고 갈 수 없음. **그룹 전용 키(비공개 `GetKey`) + `AttributeKeyHandler`의 이름 claim**으로
확정 → `base/attribute-plan.md` "이름 소유권" 절에 반영. 같이 묶여 있던
재디스패치 모델(0-A)도 같은 패스에서 `base/dispatch-core-plan.md`(신설)로
전면 반영됐고, ⚠️ 배너를 달고 있던 7개 문서 전부 갱신 완료.
- **0-Z — Attribute 이름 소유권을 무엇으로 판정할 것인가.** 재디스패치 **같은 세션에 사용자가 추가로 결정한 것** — `Tag`/`Attribute`의 알고리즘을
모델을 "하강 diff"로 재설계하면서 유일하게 안 풀린 항목. 사용자 코멘트: 통째로 quad-base로 옮기고 백엔드는 `addTag`/`removeTag`/`setAttribute` 세
"이전 결정(이름별 claimant `Relate`)을 다시 가져오는 게 맞아 보이나, 나중에 op만 주입(웹의 `className`/`data-*` 대응 때문). 상세는
제가 물리적으로 스케치해보며 심층 분석해보겠습니다." `base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절.
> **[2026-08-13 열세 번째 세션] 여기 같이 있던 `0-Y`는 해소됨 — 사람이 > **[2026-08-13 열세 번째 세션] 여기 같이 있던 `0-Y`도 해소됨** — 44개
> 결정할 게 더 없음.** 44개 스파이크 재실측으로 원인이 콜백 계약이 아니라 > 스파이크 재실측으로 원인이 콜백 계약이 아니라 **Luau 자체의 한계**임이
> **Luau 자체의 한계**(재귀 제네릭이 다른 타입 인자로 자기를 반환하면 타입 > 확정됐고, 대응은 "파생 State를 만드는 자리마다 결과 타입 명시 주석
> 안전성이 조용히 사라짐)임이 확정됐고, 사용자가 "quad가 타입을 비틀 일이 > 바인딩" 관례 하나. 규약은 `.claude/base/typing-limits.md`, 근거는
> 아니라 상위 Luau 한계이니 당장 할 수 있는 바 없다"로 정리. 계약은 그대로 > `.claude/audit/type-recursion-issue/`. 거기서 파생된 작은 확인거리
> 유지, 대응은 "파생 State를 만드는 자리마다 결과 타입 명시 주석 바인딩" > 하나(에디터의 Luau 솔버 설정)만 아래 6번에 남아 있음 — M0 착수 때
> 관례 하나. 규약은 `.claude/base/typing-limits.md`, 근거는 > 확인하면 되고 지금 막고 있진 않음.
> `.claude/audit/type-recursion-issue/`.
>
> 다만 **거기서 파생된 작은 확인거리 하나가 아래 6번으로 넘어감**(에디터의
> Luau 솔버 설정) — M0 착수 때 확인하면 되고 지금 막고 있진 않음.
**막고 있는 범위**: 0-Z가 정해져야 `bind-system-plan.md`/`tag-plan.md`/
`slot-plan.md`/`attribute-plan.md`/`ref-plan.md`/`architecture.md`/
`ROADMAP.md` 7개를 새 모델로 한 번에 옮길 수 있음 — 그 전에 M2/M4/M6/M10을
구현하면 곧 갈아엎어야 하는 코드를 짜게 됨. 상세/선택지는
`.claude/question.md` 최상단.
## 5. Studio 전용 스파이크 `10` 마저 돌리기 (사람만 가능) ## 5. Studio 전용 스파이크 `10` 마저 돌리기 (사람만 가능)
@ -120,8 +113,9 @@ M0 착수 시점에 확인하면 되고 지금 막고 있진 않음. 배경은
디자인 결정 중 Lua/Roblox 엔진에 대한 깊은 경험이 필요한 것들은 합리적 기본값으로 디자인 결정 중 Lua/Roblox 엔진에 대한 깊은 경험이 필요한 것들은 합리적 기본값으로
진행하면서 `.claude/question.md`에 모아두는 중. 깨어있을 때 훑어보고 기본값이 진행하면서 `.claude/question.md`에 모아두는 중. 깨어있을 때 훑어보고 기본값이
마음에 안 드는 것만 답해주면 됨 — **위 4번(0-Z)을 제외하면** 막고 있는 마음에 안 드는 것만 답해주면 됨 — **[2026-08-13 열네 번째 세션 기준]
항목은 없음(0-B `dispose` 시그니처는 M6 구현 세부만 막고 M0 착수는 안 막음). M0 착수를 막는 항목은 하나도 없음**(0-W `Ref` 이중 배치는 M4, 0-B
`dispose` 시그니처는 M6 구현 세부만 막음).
--- ---
Sources (MCP 리서치): [Roblox/studio-rust-mcp-server](https://github.com/Roblox/studio-rust-mcp-server), [How to Connect Claude Code to Roblox Studio — Clauder Navi](https://www.clauder-navi.com/en/claude-roblox-studio) Sources (MCP 리서치): [Roblox/studio-rust-mcp-server](https://github.com/Roblox/studio-rust-mcp-server), [How to Connect Claude Code to Roblox Studio — Clauder Navi](https://www.clauder-navi.com/en/claude-roblox-studio)

View file

@ -8,16 +8,16 @@ quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기
확정될 때마다 각 마일스톤 체크박스가 계속 갱신돼왔음 — 그래도 아직 M0 확정될 때마다 각 마일스톤 체크박스가 계속 갱신돼왔음 — 그래도 아직 M0
자체는 시작 안 함.** 다음 세션은 바로 M0부터. 자체는 시작 안 함.** 다음 세션은 바로 M0부터.
> **⚠️ M0 착수 자체가 한 결정으로 막혀 있음 — `.claude/question.md` > **✅ [2026-08-13 열네 번째 세션] M0 착수를 막던 결정이 전부 해소됐음.**
> **0-Z**(Attribute 이름 소유권). "M0 착수 전에 정할 것"으로 명시돼 > `0-Y`(13차 세션), `0-Z`(Attribute 이름 소유권)/`0-A`(재디스패치 하강
> 있음 — `HUMAN_TODO.md` 4번, `CLAUDE.md` "지금 할 일" 0번 항목 참고. > diff, 둘 다 14차 세션) 확정·반영 완료 — `.claude/question.md`의 최우선
> 아래 M0 체크리스트 자체는 이 결정과 무관하게 유효하지만, 순서상 이게 > 칸이 비었습니다.
> 먼저.**
> >
> **[2026-08-13 열세 번째 세션] 같이 막고 있던 `0-Y`(`:Compute(fn)` lazy > **다만 M0 착수 전에 반드시 읽을 구현 규약 두 개**:
> 핸들 계약)는 해소됨** — 계약은 그대로 유지로 확정, 남은 건 Luau의 현 > `.claude/base/typing-limits.md`(0-Y의 산물 — 파생 State마다 결과 타입을
> 한계라 quad가 지금 할 수 있는 게 없음. **다만 구현 시 지켜야 할 규약이 > 명시 주석으로 바인딩)와 `.claude/base/dispatch-core-plan.md`(0-A/0-Z의
> 하나 생겼으니 M0 착수 전에 반드시 읽을 것: `.claude/base/typing-limits.md`** > 산물 — 하강 diff 재디스패치, 3-인자 `retractFrom`, Handler 작성
> 체크리스트 8개, `HANDLER_PRIORITY_FALLBACK`, 주입되는 엔진 op).
> (특히 "파생 State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩"과 > (특히 "파생 State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩"과
> 7번 체크리스트). > 7번 체크리스트).
@ -76,23 +76,22 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
## M2 — 디스패치 엔진 ## M2 — 디스패치 엔진
> **⚠️ 착수 전 필독 — 아래 체크리스트는 *현행* 모델(래핑 핸들러가 재-dispatch > **✅ [2026-08-13 열네 번째 세션] 재디스패치 모델 교체 완료 — 아래
> 전에 `Dispatch.retractFrom`을 선행 호출)을 기준으로 쓰여 있고, 그 모델은 > 체크리스트는 새 모델("하강 diff") 기준으로 갱신됐습니다.** 래핑 핸들러의
> 교체가 예정돼 있습니다.** 새 모델("하강 diff": 선행 `retractFrom`을 폐기하고 > 선행 `retractFrom`은 폐기됐고, `Dispatch.process`가 슬롯의 `handler`
> `Dispatch.process`**핸들러를 먼저 비교** — 같으면 그 자리 클로저에 새 > 먼저 비교해 (같으면 그 자리 클로저에 새 값을 넘기고 자기 `process`
> 값을 넘기고 자기 `process` 재호출, 다르면 그 자리부터 전량 철거)은 > 재호출, 다르면 그 자리부터 전량 철거) 처리합니다. 정본은
> `.claude/research/dispatch-redispatch-diff-plan.md`에 있고, `.claude/question.md` > `.claude/base/dispatch-core-plan.md`(같은 세션에 `bind-system-plan.md`에서
> **0-Z**(Attribute 이름 소유권) 하나만 정해지면 base와 이 문서에 한 번에 > 분리 신설), 뒤집힌 옛 모델은
> 반영됩니다. **0-Z가 미해결인 채로 M2/M4/M10을 구현하면 곧 갈아엎어야 하는 > `.claude/archive/dispatch-hintvalue-model-reversed.md`.
> 코드를 짜게 됩니다** — 먼저 0-Z를 해소할 것. (base 4개 문서에도 같은 취지의
> ⚠️ 배너가 달려 있는데, ROADMAP에만 없어서 2026-08-13 감사에서 지적됨.)
- [ ] `Dispatch/init.luau``Dispatch.getHandler(inst,k,v): Handler?`(순수 - [ ] `Dispatch/init.luau``Dispatch.getHandler(inst,k,v): Handler?`(순수
스캔, `isHandlable`+`priority`) / `Dispatch.process(inst,k,v,index)` 스캔, `isHandlable`+`priority`) / `Dispatch.process(inst,k,v,index)`
(오케스트레이터: 그 인덱스 점유 여부 체크 → getHandler → 매치된 (오케스트레이터: getHandler → **그 인덱스의 기존 핸들러와 비교**
핸들러의 `.process`를 불러 **그 반환값(retractor 클로저)을 같으면 그 자리 클로저에 새 값을 넘기고 같은 핸들러의 `.process`
`chains`의 그 인덱스에 저장**) / `Dispatch.retractFrom(inst,k,index,v)` 자리 교체, 다르면 `retractFrom` 후 새로 설치. 반환값이 `nil`이면
즉시 error) / **3-인자** `Dispatch.retractFrom(inst,k,index)`
(아래 항목) / `Dispatch.addHandler(handler)`(레지스트리 등록, (아래 항목) / `Dispatch.addHandler(handler)`(레지스트리 등록,
quad-roblox가 팩토리 뮤테이션 시점에 호출) / `Dispatch.drive(inst, quad-roblox가 팩토리 뮤테이션 시점에 호출) / `Dispatch.drive(inst,
flattened)`(배열→해시 두 패스 순회하며 각 `(k,v)` flattened)`(배열→해시 두 패스 순회하며 각 `(k,v)`
@ -160,17 +159,19 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
바인딩), array part 모든 number 인덱스에 대해 둘 다 호출 필수(생략 바인딩), array part 모든 number 인덱스에 대해 둘 다 호출 필수(생략
UB, Handler 구현체 작성자만의 계약) — `recompute`는 leaf-lifetime UB, Handler 구현체 작성자만의 계약) — `recompute`는 leaf-lifetime
경로(`bindLifetime`/`unbindLifetime`)로 등록, `:Subscribe()` 아님 경로(`bindLifetime`/`unbindLifetime`)로 등록, `:Subscribe()` 아님
(2026-08-09 여섯 번째 세션, `base/bind-system-plan.md` "Length/Offset" (2026-08-09 여섯 번째 세션, `base/dispatch-core-plan.md` "Length/Offset"
절 — `base/slot-plan.md` "여러 Slot이 섞일 때 순서 보장" 해소) 절 — `base/slot-plan.md` "여러 Slot이 섞일 때 순서 보장" 해소)
- [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는** - [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는**
핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상 핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상
`function() end`를 반환 — `Dispatch.retractFrom`이 nil 체크 없이 `function() end`를 반환 — `Dispatch.retractFrom`이 nil 체크 없이
호출, `base/bind-system-plan.md` "핸들러 계약" 절, 2026-08-08 세션 호출, `base/dispatch-core-plan.md` "핸들러 계약" 절, 2026-08-08 세션
/ **2026-08-13 다섯 번째 세션에 별도 `retract` 필드가 `process` / **2026-08-13 다섯 번째 세션에 별도 `retract` 필드가 `process`
반환값으로 합쳐지며 대상만 바뀜**) 반환값으로 합쳐지며 대상만 바뀜**)
- [ ] 우선순위 동률/매치 실패 처리(2026-08-12 열일곱 번째 세션 확정, - [ ] 우선순위 동률/매치 실패 처리(2026-08-12 열일곱 번째 세션 확정,
`base/bind-system-plan.md` "우선순위 동률/매치 실패 처리" 절) — `base/dispatch-core-plan.md` "우선순위 동률/매치 실패 처리" 절) —
`HANDLER_PRIORITY_HIGH`/`_NORMAL`/`_LOW` 등 목적별 우선순위 상수, `HANDLER_PRIORITY_HIGH`/`_NORMAL`/`_LOW`/**`_FALLBACK`**(base 제공
핸들러의 기본 밴드 — 백엔드가 평범한 우선순위로 자기 핸들러를
등록하면 언제나 이김, 2026-08-13 열네 번째 세션 신설) 등 목적별 상수,
매치 실패(`isHandlable`을 만족하는 핸들러 없음)는 `Brand`+`typeof(v)` 매치 실패(`isHandlable`을 만족하는 핸들러 없음)는 `Brand`+`typeof(v)`
출력 후 즉시 error(provider 초기화 확인 안내 포함 — provider 출력 후 즉시 error(provider 초기화 확인 안내 포함 — provider
미주입 상태도 이 경로로 자동 커버, `pre-implementation-audit.md` 미주입 상태도 이 경로로 자동 커버, `pre-implementation-audit.md`
@ -180,25 +181,30 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
leaf 매칭 Handler, `StoreBind.luau`와 같은 층위(범용/엔진무관) — leaf 매칭 Handler, `StoreBind.luau`와 같은 층위(범용/엔진무관) —
quad-base 소속으로 확정(2026-08-08 두 번째 세션, `base/ quad-base 소속으로 확정(2026-08-08 두 번째 세션, `base/
bind-system-plan.md` "Dispatch는 프리미티브가 아니다" 절) bind-system-plan.md` "Dispatch는 프리미티브가 아니다" 절)
- [ ] `chains`(Relate 기반, `{[inst(weak)]={[k]={[index]=retractor}}}` - [ ] `chains`(Relate 기반, `{[inst(weak)]={[k]={[index]={handler, retractor}}}}`
**핸들러 배열이 아니라 재귀 깊이 인덱스→retractor 클로저 맵**) + **재귀 깊이 인덱스 → (담당 핸들러, 그가 반환한 retractor 클로저)**) +
`Dispatch.retractFrom(inst,k,index,v)` — 재귀 재-dispatch(StoreBind/ **3-인자** `Dispatch.retractFrom(inst,k,index)` — 재귀 재-dispatch
NoneHandler)의 정리를 다단 체인까지 정확히 전파(2026-08-08 세 번째 (StoreBind/NoneHandler)의 정리를 다단 체인까지 정확히 전파(2026-08-08
세션 신설, **2026-08-13 다섯 번째 세션 인덱스 기반 전면 재설계** 신설 → 2026-08-13 다섯 번째 세션 인덱스화 → **같은 날 열네 번째 세션
`base/bind-system-plan.md` "Dispatch 체인" 절, 하강 diff로 전면 교체**, `base/dispatch-core-plan.md` "Dispatch 체인"
`pre-implementation-audit.md` 1-2번 "이전 핸들러 추적" 항목 해소). 절, `pre-implementation-audit.md` 1-2번 "이전 핸들러 추적" 항목 해소).
`Dispatch.process(inst,k,v,index)`가 반환 클로저를 그 인덱스에 **구현 시 반드시 지킬 것**:
저장하는 것도 이 항목에 포함. **구현 시 반드시 지킬 것**: - **재디스패치는 하강 diff** — 래핑 핸들러는 선행 `retractFrom`
부르지 않고 그냥 `Dispatch.process(inst,k,realv,index+1)`. 비교는
`Dispatch.process` 안에서: 슬롯의 `handler`가 같으면 그 자리
클로저에 새 값을 넘긴 뒤 같은 핸들러의 `process`로 자리 교체,
다르면 `retractFrom(inst,k,index)` 후 새로 설치
- `chains:SetStrong(inst,k,list)``handler.process` 호출 **전에** - `chains:SetStrong(inst,k,list)``handler.process` 호출 **전에**
뒤에 두면 재귀 위임이 자기 테이블을 만들었다가 바깥이 덮어써 뒤에 두면 재귀 위임이 자기 테이블을 만들었다가 바깥이 덮어써
하위 retractor가 통째로 유실됨(2026-08-13 감사에서 잡힌 버그) 하위 retractor가 통째로 유실됨(2026-08-13 감사에서 잡힌 버그)
- `handler.process` 호출 전에 그 인덱스에 no-op 점유 마커를 박아 - 새 자리를 여는 (B) 분기에선 `handler.process` 호출 전에 no-op
`list`를 구멍 없는 시퀀스로 유지(hole 있는 테이블의 `#`는 Lua가 점유 마커를 박아 `list`를 구멍 없는 시퀀스로 유지(hole 있는
보장 안 함) + 같은 index 재진입도 가드에 걸리게 테이블의 `#`는 Lua가 보장 안 함)
- 점유 체크는 `getHandler`/`handler.process`보다 **먼저**(핸들러 - `process``nil`을 반환하면 (A)/(B) 양쪽에서 즉시 error
부작용 낭비 없음)
- 다른 키로 위임할 땐 항상 `index=1`, 같은 키 재귀는 `index+1`; - 다른 키로 위임할 땐 항상 `index=1`, 같은 키 재귀는 `index+1`;
`Dispatch.drive`의 진입도 항상 `1` `Dispatch.drive`의 진입도 항상 `1`
- **소유권 충돌 감지는 Dispatch의 일이 아님**(옛 점유 error 폐지) —
필요한 도메인이 직접(Attribute 이름 claim, M10)
- [ ] mock 대상 테스트 - [ ] mock 대상 테스트
## M3 — Store/State/Source ## M3 — Store/State/Source
@ -255,14 +261,14 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
## M4 — 첫 end-to-end 반응형 업데이트 ## M4 — 첫 end-to-end 반응형 업데이트
> **⚠️ M2의 ⚠️ 배너와 같은 주의** — 재디스패치 모델이 교체 예정, > **✅ [2026-08-13 열네 번째 세션] 재디스패치 모델 교체 완료** — 아래
> `question.md` 0-Z 먼저 해소할 것. > 항목은 `base/dispatch-core-plan.md`의 하강 diff 기준으로 읽을 것.
- [ ] `Dispatch/StoreBind.luau`(재귀 재실행 로직, 엔진 무관 — 재-dispatch - [ ] `Dispatch/StoreBind.luau`(재귀 재실행 로직, 엔진 무관 — **선행
`Dispatch.retractFrom(inst,k,index+1,realv)` 호출 필수, 그 다음 `retractFrom` 없이** `Dispatch.process(inst,k,realv,index+1)` 한 줄
`Dispatch.process(inst,k,realv,index+1)`. `base/bind-system-plan.md` (2026-08-13 열네 번째 세션 하강 diff), 반환 클로저는 자기 Observer
"Dispatch 체인" 절) 구독만 해제. `base/dispatch-core-plan.md` "Dispatch 체인" 절)
- [ ] mock 대상으로 "store 값 바꾸면 `process`가 다시 호출된다" + - [ ] mock 대상으로 "store 값 바꾸면 `process`가 다시 호출된다" +
"이전 값이 다른 타입이면 이전 `process`가 반환했던 retractor 클로저가 "이전 값이 다른 타입이면 이전 `process`가 반환했던 retractor 클로저가
정확히 불린다" 확인 + **`State<State<T>>`(값이 또 State/Source)가 정확히 불린다" 확인 + **`State<State<T>>`(값이 또 State/Source)가
@ -281,10 +287,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
## M6 — Slot ## M6 — Slot
> **⚠️ M2의 ⚠️ 배너와 같은 주의** — 아래 "`SlotHandler.process`는 claim > **✅ [2026-08-13 열네 번째 세션] 재디스패치 모델 교체 완료** — 아래
> 실패 시에도 파괴적 클로저를 반환해야 함(`retractFrom`은... 항상 소비)" > "`SlotHandler.process`는 claim 실패 시에도 파괴적 클로저를 반환해야 함"
> 항목은 현행(교체 예정) 재-dispatch 모델을 전제로 쓰여 있음. > 항목은 새 모델에서도 그대로 유효함(체인은 클로저를 early-return
> `question.md` 0-Z 먼저 해소할 것. > 여부와 무관하게 항상 소비 — `base/dispatch-core-plan.md` "Handler 작성
> 체크리스트" 1번). 클로저가 받는 값이 항상 `Slot`이거나 `nil`임이
> 계약으로 보장된다는 점만 새로 추가됨.
- [ ] **[2026-08-13 여섯 번째 세션 — 이 세션의 Slot 결정 전부, 구현 전 필독]** - [ ] **[2026-08-13 여섯 번째 세션 — 이 세션의 Slot 결정 전부, 구현 전 필독]**
- **`State<Slot>` 교체 = 파괴가 아니라 언마운트**(`state<Frame>`와 동일). - **`State<Slot>` 교체 = 파괴가 아니라 언마운트**(`state<Frame>`와 동일).
@ -402,7 +410,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
**`Slot.Offset: Source<number>``Slot.Length`처럼 공개 필드로 **`Slot.Offset: Source<number>``Slot.Length`처럼 공개 필드로
노출 — Slot 마운트 시점에 `Dispatch.setOffsetSource`가 등록하는 노출 — Slot 마운트 시점에 `Dispatch.setOffsetSource`가 등록하는
바로 그 Source를 `self.Offset`으로도 저장**(2026-08-11 세션, 바로 그 Source를 `self.Offset`으로도 저장**(2026-08-11 세션,
`base/bind-system-plan.md`의 "Slot.Length와 Slot.Offset은 별개" 절) `base/dispatch-core-plan.md`의 "Slot.Length와 Slot.Offset은 별개" 절)
- [ ] base `Dispatch/Slot.luau`(추상 재조정, mount/unmount/reposition 3훅) + - [ ] base `Dispatch/Slot.luau`(추상 재조정, mount/unmount/reposition 3훅) +
quad-roblox `Handlers/Slot.luau`(실제 Parent 조작 + reposition — quad-roblox `Handlers/Slot.luau`(실제 Parent 조작 + reposition —
`SetSiblingIndex` 또는 `LayoutOrder` 기반이면 no-op, 구현 선택) `SetSiblingIndex` 또는 `LayoutOrder` 기반이면 no-op, 구현 선택)
@ -504,9 +512,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
(`bind-system-plan.md`의 `None` 센티널 절, M2 dispatch 엔진의 (`bind-system-plan.md`의 `None` 센티널 절, M2 dispatch 엔진의
"이전 매치 핸들러 추적" 항목과 함께 구현 — `StoreBind` 핸들러와 "이전 매치 핸들러 추적" 항목과 함께 구현 — `StoreBind` 핸들러와
동일한 재귀 재디스패치 패턴이라 새 메커니즘 아님) — `None` 센티널 동일한 재귀 재디스패치 패턴이라 새 메커니즘 아님) — `None` 센티널
자체는 확정 완료지만, **⚠️ M2 배너와 같은 주의**: `NoneHandler` 자체는 확정 완료. **[2026-08-13 열네 번째 세션 갱신]** `NoneHandler`
쓰는 재-dispatch 배관(선행 `retractFrom` 호출)은 재디스패치 모델 쓰는 재-dispatch 배관에서 **선행 `retractFrom` 호출은 폐기됨**
교체 대상이라 `question.md` 0-Z 먼저 해소할 것 그냥 `Dispatch.process(inst,k,nil,index+1)` 한 줄
(`base/dispatch-core-plan.md`)
- [ ] 프로퍼티류 필드 타입에 `T' = T | Tween<T>` 치환 반영(타입 생성 - [ ] 프로퍼티류 필드 타입에 `T' = T | Tween<T>` 치환 반영(타입 생성
스크립트가 `Position: UDim2` 자리를 `UDim2 | Tween<UDim2>`로 만들면 스크립트가 `Position: UDim2` 자리를 `UDim2 | Tween<UDim2>`로 만들면
끝, Modifier 런타임/`__index` 자체엔 변경 없음 — `modifier-plan.md` 끝, Modifier 런타임/`__index` 자체엔 변경 없음 — `modifier-plan.md`
@ -556,9 +565,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
## M10 — Event / OnChange / Attribute / Tag ## M10 — Event / OnChange / Attribute / Tag
> **⚠️ M2의 ⚠️ 배너와 같은 주의** — 재디스패치 모델이 교체 예정이고, > **✅ [2026-08-13 열네 번째 세션] 0-Z(Attribute 이름 소유권)/0-A(하강
> 특히 **Attribute 이름 소유권(`question.md` 0-Z)이 이 마일스톤의 직접 > diff) 확정 완료** — 이 마일스톤의 Tag/Attribute 항목은 **quad-base로
> 대상**임. 0-Z 먼저 해소할 것. > 재배치**됐고(엔진 op `addTag`/`removeTag`/`setAttribute`만 주입),
> 이름 소유권은 그룹 전용 키 + `AttributeKeyHandler`의 이름 claim이
> 판정함. 정본은 `base/attribute-plan.md`/`base/tag-plan.md`.
- [ ] `Handlers/Event.luau`(`ReflectionService` 기반 자동 판별) - [ ] `Handlers/Event.luau`(`ReflectionService` 기반 자동 판별)
@ -567,33 +578,47 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
명시, 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장 명시, 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장
(`AttributeKey`와 동일 기법), `base/onchange-plan.md`, 2026-08-10 (`AttributeKey`와 동일 기법), `base/onchange-plan.md`, 2026-08-10
세션 확정·2026-08-11 아홉 번째 세션 후속(캐시)) 세션 확정·2026-08-11 아홉 번째 세션 후속(캐시))
- [ ] `Handlers/AttributeKey.luau`(단일 키 `AttributeKey<<T>>(name)`/ - [ ] **[2026-08-13 열네 번째 세션 재배치] `quad-roblox/EngineOps.luau`
`BooleanAttribute`류 DI 키 팩토리+Handler — 메커니즘/`None`/`retract` 주입되는 엔진 op 3개**: `addTag(inst,{string})`/`removeTag(inst,{string})`
불필요 확정, 이름별 weak 캐시로 동등성 보장, 타입 파라미터화 이름만 (`CollectionService`), `setAttribute(inst,name,v)`(`v==nil`이면 삭제).
착수 전 확인, `base/attribute-plan.md`) `RobloxFactory``BaseModule`에 주입(`bindLifetime`/`canExecute`와
같은 패턴) — 아래 base 핸들러들이 이걸 호출함
(`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는
엔진 op" 절)
- [ ] `quad-base/AttributeKey.luau`(단일 키 `AttributeKey<<T>>(name)` +
이름별 weak 캐시로 동등성 보장 + 스칼라 편의 패밀리
`String`/`Number`/`BooleanAttribute` — 엔진 고유 타입 패밀리
(`Color3Attribute`류)만 quad-roblox의 `D`/`DI` 층에서 각자 추가.
타입 파라미터화 이름만 착수 전 확인, `base/attribute-plan.md`)
- [ ] `quad-base/Dispatch/AttributeKey.luau`(`AttributeKeyHandler` —
`setAttribute(inst,name,v)``v`가 뭐든 무조건 호출 + **이름
claim**(`nameClaims` Relate, 다른 키 객체가 같은 이름에 들어오면
즉시 error, 반환 클로저는 자기 claim만 반납하고 엔진 부작용 없음).
`HANDLER_PRIORITY_FALLBACK`으로 등록. `question.md` 0-Z 결정 —
`base/attribute-plan.md` "이름 소유권" 절)
- [ ] `Attribute.luau`(quad-base — 그룹 값 타입+API: `Attribute(store1, - [ ] `Attribute.luau`(quad-base — 그룹 값 타입+API: `Attribute(store1,
store2, ...)`/`Merged`, `Tag`와 동형 array-part 값 객체, store2, ...)`/`Merged`/`:NameMap`, `Tag`와 동형 array-part 값 객체,
`base/attribute-plan.md`) `base/attribute-plan.md`)
- [ ] `Handlers/Attribute.luau`(quad-roblox — 그룹 `process`가 이름마다 - [ ] `quad-base/Dispatch/Attribute.luau`(`AttributeGroupHandler` — 이름마다
공개 `AttributeKey(name)``Dispatch.process(inst,key,source,1)` **그룹 전용 키**(비공개 `GetKey`, 그룹 값 객체별·이름별 메모이즈)로
부르고, 반환 클로저가 **자기가 등록한 이름 전부**에 `Dispatch.process(inst,key,source,1)`만 부르고, 반환 클로저가 자기가
`Dispatch.retractFrom(inst,key,1,nil)`. 실제 `SetAttribute`/store-bind 등록한 키 전부에 `Dispatch.retractFrom(inst,key,1)`.
구독은 전부 단일 키 경로 재사용(중복 구현 없음). **`process` 안에서 `retractFrom`을 먼저 부르면 안 됨**(철거는 전적으로
**`process` 안에서 `retractFrom`을 먼저 부르면 안 됨** — 인덱스 1이 클로저 몫). 실제 `setAttribute`/store-bind 구독/이름 claim은 전부 단일
무조건 비워져 소유권 충돌 점유 체크가 무력화됨(2026-08-13 감사), 키 경로 재사용 — `base/attribute-plan.md` "메커니즘" 절)
`base/attribute-plan.md` "메커니즘" 절)
- [ ] `Tag.luau`(quad-base — 값 타입+immutable clone 체이닝: `Tag(...)`/ - [ ] `Tag.luau`(quad-base — 값 타입+immutable clone 체이닝: `Tag(...)`/
`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`, `base/tag-plan.md` `:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`/`:Names`,
— 2026-08-08 세 번째 세션 array-part 값 객체로 재설계, 구 해시 파트 `base/tag-plan.md` — 2026-08-08 세 번째 세션 array-part 값 객체로
모델은 `archive/tag-hash-key-model-reversed.md`) 재설계, 구 해시 파트 모델은 `archive/tag-hash-key-model-reversed.md`)
- [ ] `Handlers/Tag.luau`(quad-roblox — `CollectionService` 글루만, - [ ] `quad-base/Dispatch/Tag.luau`(`TagHandler` — `isHandlable``isTag(v)`.
`isHandlable``isTag(v)`. **`AddTag`는 온전히 `process`, `RemoveTag` **`addTag`는 온전히 `process`, `removeTag`는 온전히 반환 클로저** —
온전히 반환 클로저** — 이름별 홀더 집합(`tagNameMap`, 위치 `k` 기준 이름별 홀더 집합(`tagNameMap`, 위치 `k` 기준 참조 카운트)이 비었을
참조 카운트)이 비었을 때만 실제 `RemoveTag`, 그마저도 `hintValue` 때만 실제 `removeTag`, 그마저도 클로저가 받은 새 값이 그 이름을
그 이름을 `Contains`하면 skip해 깜빡임 방지(전체 삭제 후 재생성 `Contains`하면 skip해 깜빡임 방지. 제거할 이름은 모아서 **한 번에**
금지). `process` 쪽 별도 diff 없음, `kTagMap`도 불필요(클로저가 `v` `removeTag(inst, names)`. `process` 쪽 별도 diff 없음, `kTagMap`
직접 캡처) — 2026-08-12 열한 번째 / 2026-08-13 네·다섯 번째 세션, 불필요(클로저가 `v`를 직접 캡처). `HANDLER_PRIORITY_FALLBACK`으로
`base/tag-plan.md` "메커니즘" 절) 등록 — 2026-08-12 열한 번째 / 2026-08-13 네·다섯·열네 번째 세션,
`base/tag-plan.md`)
## M11 — Tween ## M11 — Tween