docs: 코퍼스 전체 감사 + canBound/canExecute 재분리, Ref 0-W 해소

전체 .claude/ 코퍼스를 doc-check.py + 6개 병렬 서브에이전트로 감사해
stale 세션 번호, 잘못된 인용, 자기모순 배너 등 15개 파일의 실제 사실
오류를 발견·수정. 이어서 question.md 0-W(같은 Ref 객체가 두 자리에
동시에 놓이는 문제)를 선택지 (a)로 확정 — RefLeafHandler가 새 Relate
없이 bindLifetime/unbindLifetime을 재사용해 이중 배치를 방지.

이 과정에서 bindLifetime의 이중 바인딩 가드(bound 문맥)와 State emit
전파 게이팅(execute 문맥)이 서로 다른 질문인데 canExecute 하나로
뭉쳐 있던 걸 발견 — canBound를 별도 진입점으로 재도입(판정 로직은
비공개 헬퍼 하나를 공유, 코드 중복 없음). Tag/Attribute의 미지원
백엔드 처리 모델도 여러 라운드 논의 끝에 확정: TagHandler 등은
quad-base가 스스로 등록하고, addTag/removeTag/setAttribute만 백엔드
팩토리가 채우는 타입 계약 — 안 채운 슬롯은 명시적으로 에러내는 스텁.

/code-review high가 추가로 3건(gcconn-trick-verification.md 배너,
README.md 인덱스 4곳, luau-test/STATUS.md의 재작성 지침) 발견해 정정.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXqrGrh83Sh61C3tUCMdmY
This commit is contained in:
qwreey 2026-08-14 11:03:24 +09:00
parent 573dd452af
commit e35905ceca
Signed by: qwreey
GPG key ID: D28DB79297A214BD
26 changed files with 921 additions and 298 deletions

View file

@ -16,7 +16,7 @@
| `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 (구현 대상). **[2026-08-06 확장]** 완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용(제목 `[역전됨]` — 한 번 확정했다가 뒤집힌 것) — 더 이상 능동적으로 참고 안 해도 되지만(토큰 낭비 방지 위해 `base/`/`research/`에서 뺌) `quadnomicon` 소재로는 나중에 쓸 수 있음. **[2026-08-07 확장]** 후보였다가 채택 안 된 것(확정한 적 없이 검토 후 기각)도 같은 방식으로 보존, 제목은 구분을 위해 `[기각됨]``[역전됨]`과 의미가 다르므로 혼동하지 말 것. **[2026-08-07 세 번째 확장]** 설계 반전/기각과 별개로, 에이전트가 문서 작성 중 스스로 낸 개념 혼동을 정정한 이력은 `[에이전트 실수]` 태그로 `agent-mistake.md` 하나에 모음(CLAUDE.md 세션 로그 중복 방지) |
| `feedback/` | 실사용 피드백을 정리한 긴 로그 — 지금은 비어있음(구현 시작 전) |
| `luau-test/` | **[2026-08-09 신설]** `base/` 확정 사항 중 "추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 것"(M0 스파이크 대상)을 `luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 사용자가 직접 돌려볼 독립 실행 스크립트 모음. **[2026-08-13 여섯 번째 세션, 첫 실측]** `luau`/`luau-analyze` 바이너리가 생겨 처음으로 실제 실행 — **런타임 12개 전원 통과**, 타입 쪽에서 `:Compute(fn)` lazy 핸들 계약이 Luau 추론과 충돌하는 게 드러남(당시 `question.md` 0-Y). **[2026-08-13 열세 번째 세션]** 그 0-Y가 해소되며 `review-required/`**비었음** — 계약은 유지 확정, 남은 건 Luau 자체 한계라 `base/typing-limits.md`가 담당. **`STATUS.md`가 상태의 소스**(pass / 사람 결정 필요 / 스파이크 깨짐 / 미실행 분류 — 사람이 먼저 볼 것만 위에), `luau-test/README.md`는 각 파일의 검증 의도·배경, 실행 결과 상세는 `audit/luau-test-first-run-2026-08-13.md` |
| `audit/` | **[2026-08-13 신설]** `luau-test/` 등 스파이크를 실제로 돌려본 뒤 "무엇이 확인됐고 무엇이 아직 안 됐는지"를 기록하는 곳 — 스크립트/계획 자체가 아니라 **실측 결과**만 다룸. base/luau-test와 달리 부분 확인(일부만 통과)도 있는 그대로 기록, 완전히 해소되면 관련 `base/`/`luau-test/README.md` 캐비엇을 지우고 이 문서는 근거로 남김. **현재 4개**: `luau-test-first-run-2026-08-13.md`(첫 실측 라운드 전체 — 런타임 12개 통과, 구 `question.md` 0-Y의 1차 근거. **단 이 문서의 "콜백이 raw 값을 받으면 완전 클린" 판정은 아래 `type-recursion-issue/`가 뒤집었음**), `gcconn-trick-verification.md`(사용자가 Studio에서 직접 돌린 gcconn 트릭 부분 확인 — `10`의 A 섹션 앞부분만. **[2026-08-14 다섯 번째 세션]** 실측된 사실 자체는 그대로 유효하고 `canExecute(value)` 1-인자 재정정으로 오히려 더 중요해졌으나, 인용하던 `canBound`가 폐기돼 미확인 항목 목록을 새 모델 기준으로 갱신함 — 이중 바인딩 게이트/재바인딩 허용/`value` 쪽 복사 gcconn 판정/Instance userdata 동일성/B/C가 미확인), **`type-recursion-issue/`**(**[2026-08-13 열세 번째 세션 신설]** 0-Y 재실측 전체 — `REPORT.md` + `spikes/` 44개. 다른 audit 기록과 달리 **스크립트를 같이 둠**: 이 건의 근거가 "여러 formulation을 서로 대조한 것"이라 개별 파일을 직접 돌려야 판정이 재현되기 때문. 결론은 `base/typing-limits.md`로 승격됨), `fallback-xpcall-verification.md`(**[2026-08-14 신설]** `base/fallback-plan.md``Traceback` 메커니즘 전부 확인 — 클로저 업밸류 배선/중첩 스택 캡처/`err: any`/`error(msg)` 위치 접두 10개 검증 전부 통과. 스크립트 1개뿐이라 재현용으로 같이 둠: `fallback-xpcall-spike.luau`) |
| `audit/` | **[2026-08-13 신설]** `luau-test/` 등 스파이크를 실제로 돌려본 뒤 "무엇이 확인됐고 무엇이 아직 안 됐는지"를 기록하는 곳 — 스크립트/계획 자체가 아니라 **실측 결과**만 다룸. base/luau-test와 달리 부분 확인(일부만 통과)도 있는 그대로 기록, 완전히 해소되면 관련 `base/`/`luau-test/README.md` 캐비엇을 지우고 이 문서는 근거로 남김. **현재 4개**: `luau-test-first-run-2026-08-13.md`(첫 실측 라운드 전체 — 런타임 12개 통과, 구 `question.md` 0-Y의 1차 근거. **단 이 문서의 "콜백이 raw 값을 받으면 완전 클린" 판정은 아래 `type-recursion-issue/`가 뒤집었음**), `gcconn-trick-verification.md`(사용자가 Studio에서 직접 돌린 gcconn 트릭 부분 확인 — `10`의 A 섹션 앞부분만. **[2026-08-14 다섯 번째 세션, 열한 번째 세션에 `canBound` 재도입 반영해 재갱신]** 실측된 사실 자체는 그대로 유효하고 `value` 단독 1-인자 재정정으로 오히려 더 중요해졌음 — 이중 바인딩 게이트(`canBound`)/emit 게이팅(`canExecute`)/재바인딩 허용/`value` 쪽 복사 gcconn 판정/Instance userdata 동일성/B/C가 미확인), **`type-recursion-issue/`**(**[2026-08-13 열세 번째 세션 신설]** 0-Y 재실측 전체 — `REPORT.md` + `spikes/` 44개. 다른 audit 기록과 달리 **스크립트를 같이 둠**: 이 건의 근거가 "여러 formulation을 서로 대조한 것"이라 개별 파일을 직접 돌려야 판정이 재현되기 때문. 결론은 `base/typing-limits.md`로 승격됨), `fallback-xpcall-verification.md`(**[2026-08-14 신설]** `base/fallback-plan.md``Traceback` 메커니즘 전부 확인 — 클로저 업밸류 배선/중첩 스택 캡처/`err: any`/`error(msg)` 위치 접두 10개 검증 전부 통과. 스크립트 1개뿐이라 재현용으로 같이 둠: `fallback-xpcall-spike.luau`) |
| `tools/` | **[2026-08-13 아홉 번째 세션 신설]** 코퍼스 기계 점검 — `doc-check.py`가 깨진 파일/절 참조, README 색인 누락, 날짜 없는 시한부 주장("아직 안 돌려봄" 등), 미반영 ⚠️ 배너를 한 번에 훑음. **중대 변경 후 커밋 전에 돌릴 것**(`python3 .claude/tools/doc-check.py`) — 수동 감사에서 나온 발견의 대부분이 이 종류였고, 실제로 문서를 쪼개다 잘못 옮긴 참조를 이게 잡아냄. ERROR는 고치고 WARN은 판단 대상 |
| `session/` | **[2026-08-11 신설]** 세션별 상세 로그 원문(시행착오·정정 전 서술 포함, `quadnomicon` 개발로그 소재용) — 루트 `CLAUDE.md`가 3196줄까지 불어나 성능 저하를 유발해서 분리함. 파일명 `YYYY-MM-DD-NN-slug.md`, CLAUDE.md의 "세션 히스토리" 절에서 각 항목이 여기로 링크. 항상 읽을 필요 없음 — 결정의 논의 과정이 궁금할 때만 |
| `initreq/` | 프로젝트 착수 시 클론해둔 참고 레포(quad v1, fusion, vide, rbvm, tbox, code-docker) + PA님 실 코드(`artworks/`, 4차 라운드 교차검증 근거) + 원본 요청(`req.md`, `raw-userinput.md`) + `quad2-try`(이전에 시도했다 폐기한 v2 재작성 시도 — 리서치 완료, 결론은 `base/bind-system-plan.md`) — 읽기 전용 리서치 소스, 여기 내용을 옮기지 말고 항상 원본 그대로 유지 |
@ -31,7 +31,7 @@
|---|---|
| `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서). **[2026-08-12 세션 신설, 같은 날 후속 세션에서 강화]** "코드 스타일 — Luau 문법 관례" 절 신설 — `if-then-else`가 공식 Luau 문법임을 명문화(환각/오타로 오인해 `and`/`or`로 되돌리는 회귀 방지), `A and B or C` 삼항 관용구는 항상-truthy 예외도 없이 전면 금지로 강화(`bind-system-plan.md`의 `retractUnder` falsy-값 버그가 실사례). `const` 바인딩은 공식 문법이나 툴링 미성숙으로 지금은 채택 보류 |
| `typing-limits.md` | **[2026-08-13 열세 번째 세션 신설]** Luau 타입 시스템이 quad 설계에 대해 **못 해주는 것**을 한 군데 모은 확정 문서 — 여러 `base/` 문서에 캐비엇으로 흩어져 있던 걸 통합. 대전제는 "**Luau의 한계를 우회하려고 타입/API를 비틀지 않는다**"(비틀면 나중에 Luau가 고쳐줘도 자동 수혜를 못 받고 되돌리는 마이그레이션이 생김). 1번 항목이 가장 큼 — **재귀 제네릭이 다른 타입 인자로 자기를 반환하면(`Compute<U>(self: State<T>,...) -> State<U>`) 타입 안전성이 에러 없이 조용히 사라짐**(구 `question.md` 0-Y, 스파이크 44개로 확정). 대응은 두 개: (a) 타입 선언을 "데이터부/메소드부"로 쪼개 콜백 파라미터 추론을 살리고, (b) **파생 State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩**(그 한 줄만 검증 안 되고 다운스트림 전체는 정상 체크됨). Luau RFC `relax-recursive-type-restriction``Promise<T>.andThen`으로 예시 든 바로 그 패턴이라 **지금 선언 그대로 두면 Luau 쪽 수정만으로 코드 변경 없이 풀림**(추적: `luau-lang/luau#2380`). 그 외 Modifier `Overridden` 서브타입/Attribute 제네릭 키 narrowing/nilable default 오버로드/`store.key` type function 한계도 여기 통합, 7번에 **새 타입·API 설계 시 체크리스트**. 실측 근거는 `audit/type-recursion-issue/` |
| `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식. **[2026-08-14 다섯 번째 세션, 시그니처 정정]** `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canExecute(value)` — 뒤의 둘은 `inst`를 안 받음(`bindLifetime`이 바인딩 시점에 gcconn 참조를 `value``Relate`로 복사해두므로 `value` 하나로 생존을 물을 수 있고, 실제 호출부인 State 전파 루프엔 애초에 `inst`가 없음). `.Subscribed`는 전역 `:Subscribe()` 전용 필드로 분리(`bindLifetime`은 읽지도 쓰지도 않음), 별도 `canBound`는 폐기되어 `canExecute` 하나로 통합, gcconn/gchold는 lazy가 아니라 **Instance 생성 시점**에 만들고 클로저가 `gchold``inst`를 둘 다 캡처(userdata 포인터 동일성 = `inst`-키 `Relate` 전체의 전제). 옛 2-인자 모델은 `archive/canexecute-inst-arg-reversed.md` |
| `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식. **[2026-08-14 다섯 번째 세션, 시그니처 정정]** `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canExecute(value)` — 뒤의 둘은 `inst`를 안 받음(`bindLifetime`이 바인딩 시점에 gcconn 참조를 `value``Relate`로 복사해두므로 `value` 하나로 생존을 물을 수 있고, 실제 호출부인 State 전파 루프엔 애초에 `inst`가 없음). `.Subscribed`는 전역 `:Subscribe()` 전용 필드로 분리(`bindLifetime`은 읽지도 쓰지도 않음), gcconn/gchold는 lazy가 아니라 **Instance 생성 시점**에 만들고 클로저가 `gchold`와 `inst`를 둘 다 캡처(userdata 포인터 동일성 = `inst`-키 `Relate` 전체의 전제). 옛 2-인자 모델은 `archive/canexecute-inst-arg-reversed.md`. **[2026-08-14 열한 번째 세션]** 별도 `canBound`가 다시 도입됨 — `bindLifetime`/`Observer:Subscribe()`의 이중 바인딩 가드는 `canBound`, State emit 전파 게이팅만 `canExecute`(판정 로직은 비공개 헬퍼 `isBoundAlive` 하나를 공유) |
| `store-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** Store = **이름 붙은 Source 모음, 그 이상 아님** — Store 부작용 허용이 기본 디자인(국소적 vs 경계를 넘는 부작용), `defaults`는 선택적 초기값 템플릿(원본을 나중에 mutate해도 UB 아님)이고 **eager 생성과 lazy 생성이 둘 다 필요**(Luau 타입은 런타임에 강제 안 되므로), `table.clone` 기반 eager 생성 스케치, `store.key`(dot-access)가 1급 경로이고 `store "key"` 문자열 커링은 동적 키용 미타입 폴백, 레코드 필드 타이핑은 Luau `type function`으로 해결 확인, `store.key = value` 폐기 → `store.key:Set(value)`(타입 대칭성+lazy 정직성), "Store가 Store를 저장 가능한가"는 **그런 경우를 안 만듦**으로 확정(`State<State<T>>`와는 다른 축) |
| `source-state-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** 반응형 코어: `Source`⊇`State` 구조적 서브타입(`RefSource` 폐기, 단방향 의존으로 Luau 솔버 회피 — 스파이크 `08` 통과), **push-invalidate/pull-recompute** 전파 모델과 "관측해야 실체화된다" 전역 원칙, State 체인 플래튼 기각(캐싱이 State의 존재 이유), `:With`도 매번 새 노드(clone 계열인 `Tag`/`Modifier`와 혼동 주의), `:Compute`의 lazy 핸들 계약(`:Get()` 누락이 반복되는 실수)·trailing args sugar·`fn(self, previous?, ...deps)` 순서·`previous`, `:Apply`, `:Emit()`(Source 원천 전용 하드 경계)과 `Store<T>`/`Source<T>`의 `T`가 Modifier일 수 없는 따름정리, `state:Observer(fn)`, `:Subscribe()`/`:Unsubscribe()`, **이중 바인딩 금지 게이트**(`canExecute` 하나로 통합), PA님 코드 교차검증 |
| `dispatch-core-plan.md` | **[2026-08-13 열네 번째 세션 신설 — `bind-system-plan.md` 2단계 분할 + 0-A/0-Z 반영]** 디스패치 코어: 핸들러 계약(`isHandlable`/`priority`/`process`가 retract 클로저를 반환) / **하강 diff 재디스패치**(래핑 핸들러의 `retractFrom` 선행 호출 폐기, `Dispatch.process`가 슬롯의 `handler`를 먼저 비교해 — 같으면 그 자리 클로저에 새 값을 넘기고 재`process`, 다르면 그 자리부터 전량 철거) / `chains` 인덱스 체인과 **3-인자** `Dispatch.retractFrom(inst,k,index)`(힌트 인자 소멸 — 값 전달 경로가 (A) 분기 하나로 통일) / `None` 센티널 / Handler 작성 체크리스트 8개 / Length·Offset 형제 순서 보장 / "store 바인드는 래핑" 결론. **새 결정 둘**: `HANDLER_PRIORITY_FALLBACK`(base 제공 핸들러의 기본 밴드 — 백엔드가 평범한 우선순위로 덮어쓰면 언제나 이김), **"base가 소유하는 핸들러와 주입되는 엔진 op"**(부기가 엔진 지식을 요구하지 않으면 알고리즘은 base, 마지막 한 줄만 주입 — `addTag`/`removeTag`/`setAttribute`, **[2026-08-14 열 번째 세션]** 같은 패턴을 Dispatch 밖의 `dispose(value)`/`disposeInst`에도 재사용). 옛 힌트 모델은 `archive/dispatch-hintvalue-model-reversed.md` |
@ -61,7 +61,7 @@
|---|---|
| `quad-v1-architecture.md` | v1(`initreq/quad`) 내부 동작 스냅샷 — "이 문제를 안 반복하려면"의 기준선. **[2026-08-07 `base/`→`reference/` 이동]** v2의 결정 자체가 아니라 다른 문서가 인용하는 온디맨드 자료라 항상 읽을 필요는 없음 |
| `comparison-fusion-vide.md` | Fusion/Vide 아키텍처 비교 리서치 — 설계 결정 근거 자료(전파 모델 등 일부 서술은 이후 라운드에서 뒤집혔으니 `bind-system-plan.md` 쪽을 최신으로 볼 것). **[2026-08-07 `base/`→`reference/` 이동]**, `quadnomicon` 소재 후보 |
| `comparison-charm.md` | **[2026-08-09 신설]** littensy/charm(Roblox Zustand류) 비교 — `batch()`/`atom()`/수동 dispose Effect 3가지는 quad가 이미 기각한 패턴이라 반면교사, `None` 센티널은 독립 재확인, charm-sync의 diff/patch는 quad 미착수 네트워크 복제 영역의 첫 참고자료, Blocker의 "previous 값 비교" 미결 문제엔 정황 증거(생성 시 필수 `equals`, computed의 previous-in-getter) 제공 |
| `comparison-charm.md` | **[2026-08-09 신설]** littensy/charm(Roblox Zustand류) 비교 — `batch()`/`atom()`/수동 dispose Effect 3가지는 quad가 이미 기각한 패턴이라 반면교사, `None` 센티널은 독립 재확인, charm-sync의 diff/patch는 quad 미착수 네트워크 복제 영역의 첫 참고자료, 이미 확정된 `:Compute``previous` 인자(`base/source-state-plan.md`)엔 정황 증거(생성 시 필수 `equals`, computed의 previous-in-getter) 제공 |
## `research/` — 아직 착수 전, 상의 필요
@ -100,10 +100,10 @@
| `retract-always-fires-reversed.md` | **[역전됨, 2026-08-12 열한 번째 세션 신설]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff" — 실제로는 `retract`가 store 재발행마다 항상 불림(핸들러 타입 무관). `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류 위에서 설계돼 있었음이 드러나 한 세션에 전부 정정 |
| `slot-discard-no-portal-reversed.md` | **[역전됨, 2026-08-13 일곱 번째 세션 신설]** Slot의 **"retract = 폐기, 옮기지 않음"(2026-08-04 확정) + "portal은 오버엔지니어링이라 안 함"** — 여섯 번째 세션에 `State<Slot>` 교체가 파괴에서 **언마운트**로 뒤집히며 portal이 별도 기능이 아니라 그 귀결이 됨(`state<Frame>`와 동일한 시맨틱). `base/slot-plan.md`에 히스토리로 남아 있던 세 덩어리(확정 문단 + `State<Slot?>` 왕복 분석 + 포탈 검토와 숙제 셋)를 원문 그대로 이전, 숙제 셋이 각각 어떻게 결말났는지도 정리 |
| `existing-instance-bind-rejected.md` | **[기각됨, 2026-08-14 세션 — `research/`에서 이전]** 이미 생성된 Instance에 나중에 `{k=v}` 프롭 테이블을 바인드하는 기능 — 오래 "열린 가능성"으로 남겨뒀으나 사용자 확정으로 기각. 사유: 허용하면 `Dispatch.setOffsetSource`/`setLength` 같은 "quad가 만든 트리" 전제의 부기를 바깥에서 밀고 당기는 부가 작용이 전부 가능해져 **버그 표면이 치명적으로 넓어짐**. `pre-implementation-audit.md` 2-4(Slot 단일 마운트 소유권과의 충돌)도 이걸로 해소 |
| `question-resolved.md` | **[해소 아카이브, 2026-08-13 아홉 번째 세션 신설]** `question.md`에서 걷어낸 **결정 완료** 항목 전부(당시 32개 `[해소됨]` 마커) — 추가 프리미티브 필요성 라운드, 구현 착수 직전 감사 요약, 확정된 용어들(`State`/`Relate`/`List`/`canBound`(→2026-08-14 다섯 번째 세션에 폐기, `canExecute`로 통합)/`Ref`/`PreRef`/`Peek`/`isState`/`None`/`Handler`), 포탈·`State<Slot?>` 왕복 해소 등. 분리 직전 전문을 그대로 보존. **`question.md`는 이제 사용자가 답해야 할 것만 담음** — 항목이 해소되면 여기로 옮길 것 |
| `question-resolved.md` | **[해소 아카이브, 2026-08-13 아홉 번째 세션 신설]** `question.md`에서 걷어낸 **결정 완료** 항목 전부(당시 32개 `[해소됨]` 마커) — 추가 프리미티브 필요성 라운드, 구현 착수 직전 감사 요약, 확정된 용어들(`State`/`Relate`/`List`/`canBound`(2026-08-14 다섯 번째 세션에 폐기됐다가 열한 번째 세션에 별도 진입점으로 재도입 — `canExecute`와 판정 로직만 공유)/`Ref`/`PreRef`/`Peek`/`isState`/`None`/`Handler`), 포탈·`State<Slot?>` 왕복 해소 등. 분리 직전 전문을 그대로 보존. **`question.md`는 이제 사용자가 답해야 할 것만 담음** — 항목이 해소되면 여기로 옮길 것 |
| `dispatch-hintvalue-model-reversed.md` | **[2026-08-13 열네 번째 세션 신설 — 옛 이름은 research/ 아래의 dispatch-redispatch-diff-plan]** 뒤집힌 **"철거 후 재구축 + `hintValue` 힌트"** 재디스패치 모델 원문 + 역전을 이끈 분석 전문(`None`/`State` 래퍼가 힌트로 새는 재현 사례, 깊은 인덱스 힌트 유실, 옛 점유 체크가 Attribute 소유권을 대신하던 구조). 지금 유효한 모델은 `base/dispatch-core-plan.md` |
| `checkpoint-handler-pattern-reversed.md` | **[역전됨, 2026-08-13 다섯 번째 세션 신설]** `AttributeGroupHandler`의 이름 소유권 충돌을 고치려고 만든 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder` 체크포인트 핸들러 패턴(같은 날 네 번째 세션 신설) — `chains`를 핸들러 identity가 아니라 재귀 깊이 인덱스로 추적하는 더 근본적인 재설계로 대체되며 같은 날 바로 불필요해짐. `State<State<T>>`도 이 재설계로 UB에서 정상 지원 대상으로 바뀜 |
| `canexecute-inst-arg-reversed.md` | **[역전됨, 2026-08-14 다섯 번째 세션 신설]** `canExecute(inst,value)`/`unbindLifetime(inst,value)` 2-인자 시그니처와, 그 뿌리였던 **`bindLifetime``.Subscribed`를 세팅한다**는 오염(2026-08-08 다섯 번째 세션에 "재정정"으로 들어와 2026-08-09의 `canBound`까지 그 위에 세워짐) — `.Subscribed`는 전역 `:Subscribe()` 전용 필드라 leaf 경로와 무관했고, `bindLifetime`이 gcconn 참조를 `value` 쪽으로 복사해두면 `value` 하나로 생존을 물을 수 있음. 같이 폐기된 것은 `canBound(handle)`와 gcconn/gchold의 lazy 생성. 오류가 여섯 세션을 살아남은 이유(`canExecute`의 실제 호출부가 어느 문서에도 코드로 없었음)와 그 일반 교훈("계약을 정할 때 호출부를 최소 하나는 의사코드로 같이 적을 것")도 정리. 현행은 `base/lifecycle-pattern.md` |
| `canexecute-inst-arg-reversed.md` | **[역전됨, 2026-08-14 다섯 번째 세션 신설]** `canExecute(inst,value)`/`unbindLifetime(inst,value)` 2-인자 시그니처와, 그 뿌리였던 **`bindLifetime``.Subscribed`를 세팅한다**는 오염(2026-08-08 다섯 번째 세션에 "재정정"으로 들어와 2026-08-09의 `canBound`까지 그 위에 세워짐) — `.Subscribed`는 전역 `:Subscribe()` 전용 필드라 leaf 경로와 무관했고, `bindLifetime`이 gcconn 참조를 `value` 쪽으로 복사해두면 `value` 하나로 생존을 물을 수 있음. 같이 폐기된 것은 `canBound(handle)`(**2026-08-14 열한 번째 세션에 별도 진입점으로 재도입 — 이 문서 하단에 addendum**)와 gcconn/gchold의 lazy 생성. 오류가 여섯 세션을 살아남은 이유(`canExecute`의 실제 호출부가 어느 문서에도 코드로 없었음)와 그 일반 교훈("계약을 정할 때 호출부를 최소 하나는 의사코드로 같이 적을 것")도 정리. 현행은 `base/lifecycle-pattern.md` |
## 참고

View file

@ -94,8 +94,16 @@ if self.Connection then return self.Connection.Connected end
## 같이 폐기된 것
- **`canBound(handle)`** — 이 오염된 `.Subscribed` 재사용 위에 세워진
predicate라 정의 자체가 성립 안 함. `canExecute(value)` 하나로 통합
(`base/lifecycle-pattern.md`의 "`canBound` 폐기" 절).
predicate라 정의 자체가 성립 안 함. `canExecute(value)` 하나로 통합.
**[부분 되짚음, 2026-08-14 열한 번째 세션]** 이 "하나로 합친다"는
판단만 나중에 다시 갈라짐(이 문서가 고친 시그니처/오염 원인 정정은
안 바뀜) — `canBound`가 별도 진입점으로 재도입되어 `bindLifetime`/
`Observer:Subscribe()`의 "이미 묶여 있는가"(bound 문맥) 가드를 맡고,
`canExecute`는 State emit 전파 루프의 "지금 발화해도 되는가"(execute
문맥) 게이팅에만 씀 — 판정 로직(`isBoundAlive`)은 여전히 공유,
이름만 문맥별로 분리. 상세는 `base/lifecycle-pattern.md`의 "`canBound`
vs `canExecute`" 절, 계기는 `Ref` 이중 배치 방지(`question.md` 0-W,
`base/ref-plan.md` "이중 배치 방지" 절).
- **gcconn/gchold의 lazy 생성**`bindLifetime` 첫 호출에서 만들던 것을
**Instance 생성 시점**으로 올림. 이유는 이 역전과 별개(Instance userdata
포인터 동일성 — `inst`-키 `Relate` 전체의 전제), 같은 세션에 확정돼 같은

View file

@ -324,6 +324,57 @@ Slot이 됨. 막는 게 소유권 규칙이 아니라 "제거 = 파괴"라는 re
`frame:Destroy()`하고 `Set`하는 것과 같은 문제) — 순서는 항상
`Set`(언마운트) → 그 다음 정리.
### 0-W. ~~같은 `Ref` 객체가 두 자리에 놓이는 걸 막을 것인가~~ **[해소됨, 2026-08-14 열한 번째 세션]** (2026-08-13 열세 번째 세션 신설, 0-Z 확인 중 발견)
**결정: 선택지 (a) — `Slot`/`PreRef`와 같이 즉시 error.** 메커니즘은
사용자 제안대로 새 전용 `Relate`를 만들지 않고 `bindLifetime`/
`unbindLifetime`을 재사용 — `bindLifetime`이 이미 내부에 "이 value가
다른 곳에 이미 살아있는 바인딩을 갖고 있으면 즉시 error"라는 가드를
갖고 있어서(`base/lifecycle-pattern.md`의 `canBound` 게이트),
`RefLeafHandler.process`가 실제 바인딩 분기에서 `bindLifetime(inst, v)`를,
실제 언바인딩 분기에서 `unbindLifetime(v)`를 부르기만 하면 이중 배치가
저절로 막힘. 상세 메커니즘/코드는 `base/ref-plan.md`의 "이중 배치 방지"
절.
**부수 결정 — `canBound``canExecute`와 별도 진입점으로 재도입됨**
(2026-08-14 다섯 번째 세션에 "canBound 폐기, canExecute로 통합"됐던 걸
부분적으로 되짚음, 시그니처 정정 자체는 안 바뀜). 사용자 지적: "이중
바인딩 여부"(bound 문맥)와 "지금 발화해도 되는가"(execute 문맥)는 오늘
판정값이 같아도 서로 다른 질문 — `Ref`처럼 emit 전파에 참여하지 않는
값에 `canExecute`를 묻는 건 개념이 안 맞음. 판정 로직(`isBoundAlive`)은
공유하는 비공개 헬퍼 하나로 유지하고, `canBound`/`canExecute`는 그
헬퍼를 부르는 얇은 진입점으로 분리 — 중복 구현 없이 호출부 의미만
나뉨. `bindLifetime`/`Observer:Subscribe()`의 가드는 `canBound`로,
State emit 전파 루프만 `canExecute`로. 상세는
`base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절, 역전
경위는 `archive/canexecute-inst-arg-reversed.md`의 추가된 절 참고.
원 손 트레이싱/형제 프리미티브 대조표는 아래 보존(**[표기 정정, 2026-08-14
열한 번째 세션 후속]** 아래 `Frame1 { Ref = r }`/`process(inst1,"Ref",r)`의
`"Ref"`는 설명 편의상 쓴 표기일 뿐, 실제로는 `Ref`가 항상 children
배열 리터럴 아이템으로 놓여 `k`가 문자열 `"Ref"`가 아니라 그 자리의 배열
인덱스(숫자)임 — 정확한 표기는 `base/ref-plan.md` "이중 배치 방지" 절
참고, 트레이싱의 논리 자체는 `k`가 뭐든 안 바뀜):
**손 트레이싱** (`base/ref-plan.md`의 `RefLeafHandler` 의사코드에 대입,
`Frame1 { Ref = r }` / `Frame2 { Ref = r }`):
1. `process(inst1,"Ref",r)``relate[inst1]["Ref"]`가 nil → `r:Set(inst1)`
2. `process(inst2,"Ref",r)``relate[inst2]["Ref"]`도 nil(**다른 키**) →
`r:Set(inst2)` — inst1 바인딩이 **조용히 유실, 에러 없음**
3. inst1 자리가 retract → 클로저 인자 `nil ~= v(r)`**`r:Set(nil)`** —
inst2가 정당하게 들고 있던 값을 지움(교차 오염)
**형제 프리미티브 대조 — 해소 전 `Ref`만 비어 있었음**:
| | 공유 자원 | 방어 | 상태 |
|---|---|---|---|
| `Slot` | element | `claimOwner`/`claimOwnerAt` → 즉시 error(`Slot{a,a}`/`Frame{slot,slot}`) | 막힘 |
| `PreRef`/`PostRef` | 자기 자신 | `_fired` → 재사용 시 error | 막힘 |
| `Tag` | 태그 이름 | 위치별 참조 카운트 — 겹침이 **의도된 동작**(합집합) | 설계상 정상 |
| `Attribute` | 이름 | 그룹 전용 키 + 이름 claim → 즉시 error | 막힘 |
| `Ref` | 자기 자신 | `bindLifetime`/`canBound` 재사용 → 즉시 error | **막힘(해소)** |
### 0. 추가 프리미티브 필요성 — 사용자 요청, 대부분 수렴(2026-08-06~07)
사용자 질문: "다른 독립 프리미티브나 종속 파생 데이터는 뭐가 더 필요할 것

View file

@ -11,11 +11,13 @@
재정정으로 `canExecute(value)`**`value` 쪽 릴레이션에 복사된 gcconn의
`.Connected`를 직접 읽는 것**이 leaf 경로 생존 판정의 전부가 됐기 때문
(`base/lifecycle-pattern.md`의 "`bindLifetime`/`canExecute`/`unbindLifetime`
— 확정" 절). 반면 이 문서가 인용하던 **`canBound`는 폐기**됐고(게이트는
`canExecute` 하나로 통합), 공식 `10` 파일은 옛 모델을 검증 중이라
`rewrite-required/`로 옮겨졌음 — 아래 시그니처 표기와 "아직 확인 안 된
것"/"다음 확인 시 참고"를 그에 맞춰 갱신함. 역전 경위는
`archive/canexecute-inst-arg-reversed.md`.
— 확정" 절). **[재정정, 2026-08-14 열한 번째 세션] `canBound`는 폐기되지
않고 별도 진입점으로 재도입됨** — 이중 바인딩 게이트(`bindLifetime`/
`Observer:Subscribe()`)는 `canBound`, State emit 전파 게이팅만
`canExecute`(판정 로직은 비공개 헬퍼 하나를 공유, `base/lifecycle-pattern.md`
"`canBound` vs `canExecute`" 절) — 공식 `10` 파일은 이 재분리도 반영해
재작성해야 함, 계속 `rewrite-required/`에 있음. 역전 경위는
`archive/canexecute-inst-arg-reversed.md`(추가된 절 포함).
## 배경
@ -75,18 +77,19 @@ Studio에서 실행된 사용자 자작 스크립트(공식 `10` 파일이 아
- **이중 바인딩 게이트 + unbind/Destroy 후 재바인딩 허용**`bindLifetime`/
`unbindLifetime` 로직 자체는 이 스크립트에 없음(순수 GC/Connection
메커니즘만 테스트함). **[2026-08-14 다섯 번째 세션 갱신]** 게이트는 이제
`canBound`가 아니라 `canExecute(value)` 하나이고(`if canExecute(v) then
error(...) end`), 검증해야 할 명제도 바뀜: (a) 살아있는 바인딩을 가진
메커니즘만 테스트함). **[2026-08-14 열한 번째 세션 재갱신]** 게이트는
`canBound(value)`이고(`if canBound(v) then error(...) end`, `canExecute`
emit 게이팅 전용으로 분리 — `lifecycle-pattern.md` "`canBound` vs
`canExecute`" 절), 검증해야 할 명제는 안 바뀜: (a) 살아있는 바인딩을 가진
값을 다시 `bindLifetime`하면 error, (b) `unbindLifetime(value)` 후에는
통과, (c) **`inst`가 Destroy된 뒤에도 통과**(새 모델이 명시적으로
허용 — `lifecycle-pattern.md` "`canBound` 폐기" 절). 전부 미해소이고,
공식 `10` 파일은 **재작성 후에야** 이걸 확인할 수 있음(현재
`luau-test/rewrite-required/`).
통과, (c) **`inst`가 Destroy된 뒤에도 통과**(모델이 명시적으로 허용).
전부 미해소이고, 공식 `10` 파일은 **재작성 후에야** 이걸 확인할 수
있음(현재 `luau-test/rewrite-required/`).
- **`bindLifetime`이 복사해둔 gcconn만으로 판정이 성립하는가** —
**[2026-08-14 다섯 번째 세션 신규]** `canExecute``inst`를 안 받고
`BindData:GetWeak(value, "gcconn")` 하나로 생존을 판정하는 경로 자체는
아직 실측된 적 없음(위 3번은 gcconn을 `inst` 쪽에서 직접 들고 있는
**[2026-08-14 다섯 번째 세션 신규]** `canBound`/`canExecute`가 `inst`
안 받고 `BindData:GetWeak(value, "gcconn")` 하나로 생존을 판정하는
경로 자체는 아직 실측된 적 없음(위 3번은 gcconn을 `inst` 쪽에서
직접 들고 있는
형태로 확인한 것). weak 릴레이션에 복사해둔 참조가 gchold 사망 후
기대대로 비워지는지도 같은 항목.
- **Instance userdata 포인터 동일성****[2026-08-14 다섯 번째 세션 신규]**

View file

@ -168,12 +168,12 @@ quad/
│ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process — process가 자기 retract 클로저를 반환)
│ │ ├── StoreBind.luau # store 값 재귀 재실행 로직(범용, 엔진 무관)
│ │ ├── Leaf.luau # (i:number, v=Ref/Observer/PreRef/PostRef) children-array leaf 매칭 Handler(일반 Ref 매치는 `isRef(v) and not isPreRef(v) and not isPostRef(v)`), StoreBind와 같은 층위(범용/엔진무관, 2026-08-08 두 번째 세션 확정)
│ │ ├── Tag.luau # TagHandler — 이름별 참조 카운트(`tagNameMap`), 실제 호출은 주입된 addTag/removeTag(inst, {string}). HANDLER_PRIORITY_FALLBACK으로 등록(`base/tag-plan.md`, 2026-08-13 열네 번째 세션 base로 이동)
│ │ ├── Tag.luau # TagHandler — 이름별 참조 카운트(`tagNameMap`), 실제 호출은 주입된 addTag/removeTag(inst, {string}). quad-base가 모듈 로드 시점에 priority=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 재조정 로직(추상 자식 참조 기준)
│ ├── Relate.luau # inst를 weak 키로 하는 범용 릴레이션(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`), 비싱글톤 생성자(`base/relate-plan.md`) — 구 PerInstanceState/perInstanceState 대체
│ ├── LifetimeHandle.luau # `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canExecute(value)` 탑레벨 함수 "인터페이스"(타입/계약만), 내부는 Relate 사용(`base/lifecycle-pattern.md`)
│ ├── LifetimeHandle.luau # `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canBound(value)`/`canExecute(value)` 탑레벨 함수 "인터페이스"(타입/계약만), 내부는 Relate 사용(`base/lifecycle-pattern.md`)
│ ├── Ref.luau # 범용 값 박스(.Value 읽기 + :Set()/:Callback()/:Wait() 셋), `Ref(default)`를 children 배열 숫자 슬롯에 직접 놓으면 (v=Ref) 매치 핸들러가 바인드 — 별도 CreatedRef 래퍼 없음
│ ├── PreRef.luau # Ref 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, 호이스팅되는 pre-pass 특수화(별도 파일, `ref-plan.md` "PreRef 신설" 절, 2026-08-07 여섯 번째 세션에서 분리)
│ ├── PostRef.luau # PreRef의 거울상 — 같은 Ref 런타임/제약, 같은 pre-pass가 수집만 하고 두 패스가 전부 끝난 뒤 fire(`ref-plan.md` "`PostRef`" 절, 2026-08-14 아홉 번째 세션 확정)
@ -182,9 +182,9 @@ quad/
└── quad-roblox/
├── wally.toml
└── src/
├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) — 주입 대상엔 bindLifetime/canExecute 외에 addTag/removeTag/setAttribute도 포함(2026-08-13 열네 번째 세션)
├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) — 주입 대상엔 bindLifetime/canBound/canExecute 외에 addTag/removeTag/setAttribute도 포함(2026-08-13 열네 번째 세션)
├── EngineOps.luau # 주입되는 엔진 op 구현: addTag(inst,{string})/removeTag(inst,{string})=CollectionService, setAttribute(inst,name,v)=inst:SetAttribute(v==nil이면 삭제), disposeInst(inst)=inst:Destroy()(`dispose(value)`가 `isSlot`이 아닐 때 위임, `base/slot-plan.md`) (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절)
├── LifetimeHandle.luau # bindLifetime/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetStrong으로 gcconn/gchold 저장(`base/lifecycle-pattern.md`). Relate 자체는 순수 Lua라 quad-roblox 쪽 재구현 없음(quad-base 그대로 재사용)
├── LifetimeHandle.luau # bindLifetime/canBound/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetStrong으로 gcconn/gchold 저장(`base/lifecycle-pattern.md`). `canBound`/`canExecute`는 비공개 헬퍼 하나를 공유하는 얇은 진입점(2026-08-14 열한 번째 세션). Relate 자체는 순수 Lua라 quad-roblox 쪽 재구현 없음(quad-base 그대로 재사용)
├── Handlers/
│ ├── Property.luau # 일반 프로퍼티 세팅 + `isTween(realv)` 분기(3-상태 릴레이션 슬롯 `RobloxTween|true|nil`, hasBeenSet 억제, override 정책) — 구 `Handlers/Tween.luau`(높은 우선순위 store-bind 핸들러)는 폐기(`archive/tween-special-bind-key-reversed.md`)
│ ├── Event.luau # ReflectionService 기반 자동 판별

View file

@ -446,7 +446,7 @@ quad-roblox** 소속이었음 — 그런데 실제로 엔진에 종속된 건
| 그룹 값 타입+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`으로 등록 |
| `AttributeKeyHandler`(이름 claim 포함) / `AttributeGroupHandler`(전용 키 위임) | quad-base, `HANDLER_PRIORITY_FALLBACK`으로 quad-base가 스스로 등록 |
| 엔진 고유 타입 패밀리(`Color3Attribute`/`UDim2Attribute`/`InstanceAttribute`류) | 백엔드(quad-roblox의 `D`/`DI` 층) |
| **`setAttribute(inst, name, v)`** — `v == nil`이면 그 이름을 지움 | 백엔드가 주입 |
@ -455,10 +455,18 @@ quad-roblox** 소속이었음 — 그런데 실제로 엔진에 종속된 건
수 없음. 반대로 string/number/boolean은 어느 백엔드에나 있으므로 base에
둔다. "이 값이 이 백엔드에서 표현 가능한가"라는 **검증도 base가 아니라
주입된 `setAttribute`의 몫** — base는 값을 그대로 흘려보냄.
- **백엔드가 통째로 다르게 하고 싶으면** 평범한 우선순위로 자기 핸들러를
등록하면 됨(base 것은 최하위 밴드라 자동으로 짐) — 상세는
`base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진
op" 절. `Tag`도 정확히 같은 구조(`base/tag-plan.md`).
- **`AttributeKeyHandler`/`AttributeGroupHandler` 자신은 quad-base가
모듈 로드 시점에 스스로 등록** — `setAttribute`만 백엔드 팩토리가
채우는 타입 계약, 안 채운 슬롯의 base 기본값은 명시적으로 에러내는
스텁(2026-08-14 열한 번째 세션 — 한때 "등록 자체가 백엔드 선택"으로
잘못 정정됐다가 철회됨). 더 명확한 메시지나 진짜 원자적 실패를 원하는
백엔드는 opt-in으로 `HANDLER_PRIORITY_FALLBACK + 1`짜리 가로채기
Handler를 추가로 등록 가능. 속성 처리 자체를 통째로 다른 알고리즘으로
바꾸고 싶은 백엔드는 `HANDLER_PRIORITY_FALLBACK`보다 확실히 높은
우선순위로 자기 Handler를 등록하면 base 것을 완전히 대체함(같은
override 원리) — 상세는 `base/dispatch-core-plan.md`의 "base가
소유하는 핸들러와 주입되는 엔진 op" 절. `Tag`도 정확히 같은
구조(`base/tag-plan.md`).
- 단일 키를 별도 opt-out 패키지로 쪼개지 않는다는 기존 판단은 그대로
(UICorner 숏핸드/Tween/Tag와 같은 결) — 다만 "어느 패키지의 코어인가"가
quad-roblox에서 quad-base로 바뀐 것.

View file

@ -537,9 +537,7 @@ end
(재조정 알고리즘은 base `Dispatch/Slot.luau`, 물리 마운트만 backend) —
이들은 "한 줄 op"으로 줄어들지 않으므로 그대로 backend.
**주입되는 엔진 op(base는 시그니처만 소유, 실제 구현은
`RobloxFactory(BaseModule)`류가 뮤테이션으로 주입 — `bindLifetime`/
`canExecute`와 완전히 같은 패턴, 새 메커니즘 아님)**:
**주입되는 엔진 op**:
```lua
addTag(inst: any, names: {string}): () -- 웹은 className을 한 번에 갱신
@ -558,13 +556,52 @@ setAttribute(inst: any, name: string, v: any?): () -- v == nil이면 그 이름
`SetAttribute`의 네이티브 동작과 일치하고, 다른 백엔드는 자기 방식으로
매핑하면 됨(웹이면 `removeAttribute`). base 쪽 규칙 — "Attribute는 오직
명시적 `None`/`nil`로만 지워진다"(`base/attribute-plan.md`) — 은 그대로.
- **미주입 백엔드의 실패 모드**: base가 이 핸들러들을
`HANDLER_PRIORITY_FALLBACK`(위 "핸들러 계약" 절)으로 등록하므로,
백엔드가 자기 핸들러를 따로 등록했다면 그쪽이 항상 이기고 base 것은
아예 안 불림. 아무도 안 가져갔는데 op도 주입 안 된 백엔드라면 그때
**"이 백엔드는 `addTag`를 구현하지 않음"** 같은 명확한 에러를 내는 게
base 기본 스텁의 역할 — "매치 실패=error"(위 절)와 층위만 다른, 같은
성격의 즉시 실패.
**[재정정, 2026-08-14 열한 번째 세션 — 앞선 "등록 자체도 백엔드의
선택" 안은 틀렸음, 철회] `TagHandler`/`AttributeKeyHandler`/
`AttributeGroupHandler`는 quad-base가 자기 모듈 로드 시점에
`HANDLER_PRIORITY_FALLBACK`으로 스스로 `Dispatch.addHandler` 등록한다
— 이게 기본이고 필요함.** `HANDLER_PRIORITY_FALLBACK`이라는 밴드
자체가 정확히 이런 용도 — "아무도 이 자리를 안 가져갔을 때의 안전한
기본 동작"을 base가 공짜로 제공하는 것. 모든 백엔드가 자동으로
`Tag`/`Attribute` 부기(참조 카운트/이름 claim)를 얻고, 특별히 뭔가를
하지 않아도 이 값들이 어떤 자리에 놓이든 최소한 매치는 됨.
`addTag`/`removeTag`/`setAttribute`는 base가 시그니처만 소유하고
실제 구현은 팩토리가 뮤테이션으로 주입하는 **타입 계약**(`bindLifetime`/
`canExecute`와 같은 패턴, 엔진이 실제로 손대는 부분은 백엔드가 채우기로
"계약"한 것) — 이건 그대로 유지:
- **아직 아무 팩토리도 채우지 않은 슬롯의 기본값은 quad-base가 준다 —
단 "동작하는 구현을 추측"하지 않고 명시적으로 에러내는 스텁으로.**
`BaseModule.addTag = function() error("addTag가 구현되지 않음 —
provider가 초기화됐는지, 이 백엔드가 Tag를 지원하는지 확인하라") end`
류. base가 "그럴듯한 기본 동작"(예: 조용한 no-op)을 대신 만들어주는
건 기각 — 임의의 엔진에 뭐가 맞는 기본값인지 base는 알 수 없고,
조용한 no-op은 실수(provider 초기화를 잊음)를 가려버림. 명시적 에러가
유일하게 안전한 기본값.
- **"provider 미주입"과 "이 백엔드가 애초에 Tag를 지원 안 함"은 이
기본 스텁 수준에서 여전히 구분 안 됨** — 둘 다 그 슬롯이 안
채워진 같은 상태라 원천적으로 구별 불가(`pre-implementation-audit.md`
1-4, 2026-08-12 열일곱 번째 세션 확정 원칙 그대로).
- **[관례, opt-in] 더 명확한 메시지나 진짜 원자적 실패(부기 mutation
0회)를 원하는 백엔드는, 그거대로 `HANDLER_PRIORITY_FALLBACK + 1`
우선순위의 얇은 가로채기 Handler를 추가로 등록할 수 있음**:
```lua
{ priority = HANDLER_PRIORITY_FALLBACK + 1,
isHandlable = function(inst,k,v) return isTag(v) end,
process = function(inst,k,v) error("이 백엔드는 Tag를 지원하지 않음") end }
```
`TagHandler` 자신(`FALLBACK`)보다 한 단계 높아 스캔에서 먼저 매치되고,
"매치된 Handler 하나만 실행"이라는 기존 규칙 덕분에 `TagHandler.process`
(와 그 안의 `tagNameMap` mutation)는 아예 안 불림 — op 에러보다
이르고 정확한, 진짜 원자적 실패. 단 이건 **선택적 업그레이드**일 뿐
기본 요구사항은 아님 — base 기본 스텁 하나로도 이미 충분히 안전하게
실패함(`AttributeGroupHandler`의 "부분 실패 경로" 절이 이미 정리한
"에러=패닉 상태, 그 이후 정합성은 관리 대상 아님" 원칙 + `nameClaims`/
`tagNameMap``inst`에 대해 weak라 그 인스턴스가 GC되면 잔여 부기도
같이 사라지는 것으로 충분히 커버됨), 더 깔끔한 실패를 원하는 백엔드만
추가로 얹으면 됨.
- **타입 패밀리는 백엔드 몫**: `AttributeKey<<T>>` 제네릭 생성자와
스칼라 편의 패밀리(`StringAttribute`/`NumberAttribute`/`BooleanAttribute`)
까지가 base이고, `Color3Attribute`류처럼 **엔진 고유 타입**에 묶인
@ -1263,7 +1300,7 @@ end
전파 루프가 발화 때마다 `canExecute(observer)`로 각 구독자를 게이팅하고,
그 판정 근거(`inst` 생존)는 `bindLifetime``observer` 쪽에 복사해둔
gcconn 참조가 제공함(`base/lifecycle-pattern.md`의
"`bindLifetime`/`canExecute`/`unbindLifetime`" 절).
"`bindLifetime`/`canBound`/`canExecute`/`unbindLifetime`" 절).
**[정정, 2026-08-14 다섯 번째 세션]** 이 항목의 옛 근거(*"Observer가 이미
자기 `Subscribed` 상태로 게이팅됨, `bindLifetime`도 그 필드를 세팅/해제"*)는
틀렸음 — `.Subscribed`는 전역 `:Subscribe()` 전용 필드이고 `bindLifetime`

View file

@ -70,6 +70,17 @@ leaf가 살아있는 동안만 유효, leaf가 죽으면 최종 정리 콜백
leaf당 실제 Destroying 바인딩 하나(공유 weak table로 되는 Observer보다
비쌈) — 필요할 때만 쓰는 걸로 충분.
**동적 경로 가드 — `k` 무관 매치, `HANDLER_PRIORITY_FALLBACK`
(2026-08-14 열한 번째 세션, `PreRef`/`Observer`와 같은 패턴, `base/
source-state-plan.md`의 "동적 경로 가드" 절 참고).** `EffectHandle`
children 배열 리터럴 전용이라, 해시 파트 named 자리 등으로 동적으로
흘러들어오면 명확히 에러내야 함 — `{ priority = HANDLER_PRIORITY_FALLBACK,
isHandlable = function(inst,k,v) return isEffect(v) end, process =
function(inst,k,v) error("EffectHandle은 children 배열 리터럴에만 놓을
수 있음") end }`. `FALLBACK`인 이유도 동일 — 하드 블록이 아니라 나중에
named 자리 바인드 같은 실제 기능이 확정되면 평범한 우선순위의 Handler로
값싸게 override 가능한 자리로 열어둠.
**보강 — `EffectHandle`의 내부 Observer 바인딩 세부(2026-08-09 열한 번째
세션, 재확인 후 명시화)**:
@ -159,10 +170,12 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로
일곱 번째 세션 후속)**: 처음엔 "같은 liveness 게이트를 공유하니
동시에 써도 안전"으로 적었으나, 애초에 한 핸들은 라이프사이클 바인딩
경로를 하나만 가져야 한다는 게 맞는 방향이라 판단이 뒤집힘 — 상세
규칙과 `canExecute(value)` 기반 즉시-에러 메커니즘(구 가칭 `Bound`
플래그 → 2026-08-09 세션에 `canBound`로 명명 → **2026-08-14 세 번째
세션에 `canBound` 폐기, `canExecute`로 통합**)은
`base/source-state-plan.md`의 "이중 바인딩 금지" 절 참고. **[정정,
규칙과 `canBound(value)` 기반 즉시-에러 메커니즘(구 가칭 `Bound`
플래그 → 2026-08-09 세션에 `canBound`로 명명 → 2026-08-14 다섯 번째
세션에 `canBound` 폐기, `canExecute`로 통합 → **같은 날 열한 번째
세션에 `canBound`가 별도 진입점으로 재도입**, 판정 로직은
`canExecute`와 공유)은 `base/source-state-plan.md`의 "이중 바인딩
금지" 절 참고. **[정정,
2026-08-09 여섯 번째 세션] leaf 부착 후 조기 해제는 `:Unsubscribe()`
아니라 `unbindLifetime(value)`** — leaf 부착 자체가 내부적으로
`bindLifetime(inst, value)` 호출이라, 그 해제도 짝인 `unbindLifetime`

View file

@ -130,21 +130,23 @@ GC에 묶이지 않음 — v1이 여기저기서 `PropertyChangedSignal`에 연
도구로 바인드된 옵저버는 `canExecute` predicate로 게이팅되어, 살아있지 않으면
실행 자체를 건너뛸 수 있음(죽은 대상에 대한 처리 시도 방지, 위 원칙과 직결).
### `bindLifetime`/`canExecute`/`unbindLifetime` — 확정(2026-08-08 세션,
`unbindLifetime`은 2026-08-09 세션 추가, **시그니처는 2026-08-14 세 번째
세션에 `value` 단독으로 최종 정정**)
### `bindLifetime`/`canBound`/`canExecute`/`unbindLifetime` — 확정(2026-08-08 세션,
`unbindLifetime`은 2026-08-09 세션 추가, **시그니처는 2026-08-14 다섯
번째 세션에 `value` 단독으로 최종 정정**, **`canBound`는 2026-08-14 열한
번째 세션에 별도 진입점으로 재도입** — 아래 "(3)" 절)
**탑레벨 평범한 함수로 확정, 네임스페이스에 안 숨김.** `Dispatch.process`/
`Handler.xxx`는 "시스템 내부 배관"이라 네임스페이스가 맞지만, `bindLifetime`/
`canExecute`/`unbindLifetime`는 `isState`/`isObserver`처럼 핸들러 작성자가
직접 호출하는 **1급 프리미티브 연산**이라 `LifetimeHandle.bind(...)`류로
감싸면 안 됨 — `LifetimeHandle.luau` 파일 안에 있어도 되지만 export는
평평한 함수:
`canBound`/`canExecute`/`unbindLifetime`는 `isState`/`isObserver`처럼
핸들러 작성자가 직접 호출하는 **1급 프리미티브 연산**이라
`LifetimeHandle.bind(...)`류로 감싸면 안 됨 — `LifetimeHandle.luau` 파일
안에 있어도 되지만 export는 평평한 함수:
```lua
bindLifetime(inst: any, value: any): () -- inst가 필요한 건 이것 하나뿐
unbindLifetime(value: any): ()
canExecute(value: any): boolean
canBound(value: any): boolean -- "이미 유효하게 묶여 있는가" — 구조적 점유 확인
canExecute(value: any): boolean -- "지금 발화해도 되는가" — emit 전파 게이팅
```
**[정정, 2026-08-14 다섯 번째 세션] `unbindLifetime`/`canExecute`는 `inst`
@ -237,17 +239,36 @@ InstData:SetWeak(inst, "gcconn", gcconn)
생기므로(예: `dispatch-core-plan.md``StoreBind.process`), 이번
변경은 "아무것도 안 걸린 Instance"까지 같은 규칙으로 통일한 것뿐.
#### (1) `bindLifetime` / `unbindLifetime` / `canExecute`
#### (1) `bindLifetime` / `unbindLifetime` / `canBound` / `canExecute`
```lua
-- quad-roblox 실 구현 스케치
local InstData = Relate() -- inst -> gchold/gcconn (위 (0)에서 채워짐)
local BindData = Relate() -- value -> gchold/gcconn (bindLifetime이 채움)
-- 비공개(export 안 함) — canBound/canExecute가 공유하는 실제 판정.
-- 이 값이 "구조적으로 이미 살아있는 바인딩을 갖고 있는가"는 어느 쪽
-- 진입점에서 물어도 항상 같은 값이라, 판정 로직은 여기 하나만 있음.
local function isBoundAlive(value)
-- (a) inst-scoped 경로: bindLifetime이 복사해둔 gcconn을 value 자신에게서 찾음.
-- inst가 Destroy되면 Connected가 즉시 false, 이후 GC가 항목까지 치움
-- (gchold가 죽으면 gcconn을 강참조하는 게 없어지므로 weak 항목이 스스로 비워짐).
local gcconn = BindData:GetWeak(value, "gcconn")
if gcconn ~= nil and gcconn.Connected then
return true
end
-- (b) 전역 경로: :Subscribe()가 세운 것. Observer/Effect에만 있는 필드.
if isObserver(value) or isEffect(value) then
return value.Subscribed == true
end
return false
end
function bindLifetime(inst, value)
-- 이중 바인딩 금지(base/source-state-plan.md) — 게이트가 곧 canExecute.
-- "지금 실행 가능하다"는 곧 "이미 유효한 바인딩을 갖고 있다"는 뜻.
if canExecute(value) then
-- 이중 바인딩 금지(base/source-state-plan.md) — 게이트는 canBound.
-- "이미 유효한 바인딩을 갖고 있다"를 묻는 자리이지 "지금 발화해도
-- 되는가"를 묻는 자리가 아님(둘의 구분은 아래 "(3)" 절 참고).
if canBound(value) then
-- 어느 경로로 묶여있는지만 메시지에 실어줌. `.Subscribed`를 무조건
-- 인덱싱하면 안 됨 — 게이트는 값 타입을 안 가려서 value가 평범한
-- 클로저일 수도 있음(그 경우 필드 접근 자체가 에러).
@ -275,19 +296,20 @@ function unbindLifetime(value)
BindData:SetWeak(value, "gcconn", nil)
end
-- "이미 유효하게 묶여 있는가" — 구조적 점유 확인용. bindLifetime의 이중
-- 바인딩 가드, Observer:Subscribe()의 이중 등록 가드, Ref가 두 자리에
-- 동시에 놓이는 걸 막는 가드(`question.md` 0-W, `base/ref-plan.md`)처럼
-- "이 값이 이미 다른 어딘가에 물려 있는가"를 묻는 자리는 전부 이걸 씀.
function canBound(value)
return isBoundAlive(value)
end
-- "지금 발화해도 되는가" — State emit 전파 루프가 구독자를 게이팅할
-- 때만 씀(아래 "(4) 실제 호출부" 절). 오늘은 canBound와 판정값이 항상
-- 같지만(같은 isBoundAlive를 공유), 호출부의 질문 자체가 다르므로
-- 이름을 분리해둔다.
function canExecute(value)
-- (a) inst-scoped 경로: bindLifetime이 복사해둔 gcconn을 value 자신에게서 찾음.
-- inst가 Destroy되면 Connected가 즉시 false, 이후 GC가 항목까지 치움
-- (gchold가 죽으면 gcconn을 강참조하는 게 없어지므로 weak 항목이 스스로 비워짐).
local gcconn = BindData:GetWeak(value, "gcconn")
if gcconn ~= nil and gcconn.Connected then
return true
end
-- (b) 전역 경로: :Subscribe()가 세운 것. Observer/Effect에만 있는 필드.
if isObserver(value) or isEffect(value) then
return value.Subscribed == true
end
return false
return isBoundAlive(value)
end
```
@ -296,7 +318,7 @@ end
1. **바인딩이 유효한 동안 `value`는 최소한 `inst`만큼은 산다**`gchold[value]`
강참조가 그것.
2. **`value``inst`가 살아있는지 스스로 확인할 방법을 갖는다** — `BindData`
복사된 gcconn 참조가 그것. `canExecute``inst` 없이 성립하는 이유.
복사된 gcconn 참조가 그것. `canBound`/`canExecute`가 `inst` 없이 성립하는 이유.
**`Subscribed`는 이 계약과 일절 무관하다 — 오직 전역 `:Subscribe()` 경로
전용 필드.** `bindLifetime`/`unbindLifetime`은 이 필드를 **읽지도 쓰지도
@ -309,13 +331,13 @@ end
`inst`에 안 묶이는(모듈 최상위 디버그 print류) Observer/Effect 전용. 상세
규칙과 경고는 `base/source-state-plan.md`의 "`:Subscribe()`/`:Unsubscribe()`"
절이 소스이고, 여기선 `canExecute`가 보는 상태만 못박음:
절이 소스이고, 여기선 `canBound`가 보는 상태만 못박음:
```lua
local Subscribed = {} -- 전역 강참조 레지스트리(weak 아님 — 살려두는 게 목적)
function Observer:Subscribe()
if canExecute(self) then -- bindLifetime과 정확히 같은 게이트
if canBound(self) then -- bindLifetime과 정확히 같은 게이트(같은 isBoundAlive 공유)
error(if self.Subscribed
then "이미 :Subscribe()된 값"
else "이미 Instance에 바인딩된 값")
@ -333,26 +355,57 @@ end
```
`.Subscribed` 필드와 `Subscribed` 테이블이 **둘 다** 있는 이유: 테이블은
강참조 루트(생존 보장), 필드는 `canExecute`가 매 발화마다 읽는 O(1) 경로 +
에러 메시지에서 "전역이냐 leaf냐"를 가르는 판별자. 둘은 항상 같이
강참조 루트(생존 보장), 필드는 `canBound`/`canExecute`가 매번 읽는 O(1)
경로 + 에러 메시지에서 "전역이냐 leaf냐"를 가르는 판별자. 둘은 항상 같이
쓰고 같이 지우는 한 세트(`:Unsubscribe()`가 필드만 내리고 테이블을 안
비우면 반쪽짜리 해제가 됨 — `base/source-state-plan.md`에 이미 확정된 규칙
그대로).
#### (3) `canBound` 폐기 — 게이트는 `canExecute` 하나
#### (3) `canBound` vs `canExecute` — 문맥이 달라 다시 갈라짐
**[역전, 2026-08-14 다섯 번째 세션]** 2026-08-09 세션에 이름 확정됐던
별도 predicate `canBound(handle)`("아직 어느 경로로도 안 묶였는가")은
**폐기하고 `canExecute(value)`로 통합**. 두 질문이 사실 같은 질문이기
때문 — "이미 유효하게 묶여 있다"와 "지금 실행 가능하다"가 정확히 같은
조건(위 구현의 (a) OR (b))이고, `canBound`의 내부 근거로 지목돼 있던
`.Subscribed` 필드는 애초에 leaf 경로와 무관했으므로 그 정의 자체가
성립하지 않았음.
**[2026-08-14 열한 번째 세션, 다섯 번째 세션의 "canBound 폐기" 결정을
부분적으로 되짚음]** 원래 폐기 서사·오염 경로 추적은
`archive/canexecute-inst-arg-reversed.md`에 그대로 있음(그 문서가 고친
버그 — 2-인자 `canExecute(inst,value)`가 오염이었다는 것, `unbindLifetime`/
`canExecute``inst`를 안 받아야 한다는 것 — 은 전부 그대로 유효, 이번에
되짚는 건 "판정을 하나의 이름으로 합칠지 두 이름으로 나눌지"뿐).
부수 효과로 **"바인딩이 죽은 뒤의 재사용은 허용"**이 명시적 의미를 얻음 —
`inst`가 Destroy됐거나 `unbindLifetime``value``canExecute`가 거짓이라
게이트를 통과함(다시 다른 `inst`에 걸 수 있음). 살아있는 바인딩만 막는 게
이 게이트의 의도.
**왜 다시 나눴나(사용자 판단, `question.md` 0-W 논의 중 제기)**:
`canExecute`라는 이름 하나가 실제로는 서로 다른 두 호출 맥락을 겸하고
있었음:
1. **bound 문맥 — "이 값이 이미 어딘가에 유효하게 묶여 있는가"**(구조적
점유 여부를 묻는 질문). `bindLifetime`의 이중 바인딩 가드,
`Observer:Subscribe()`의 이중 등록 가드, 그리고 `Ref`가 두 자리에
동시에 놓이는 걸 막는 가드(`question.md` 0-W, `base/ref-plan.md`
"이중 배치 방지" 절)가 전부 이 질문만 물음 — 이 값들은 emit 전파에
참여조차 안 하는 경우도 있음(`Ref`가 그 예).
2. **execute 문맥 — "지금 이 구독자가 발화해도 되는가"**. State emit
전파 루프가 매 발화마다 각 구독자에게만 묻는 질문(아래 "(4)" 절) —
`Effect`/`Observer`처럼 실제로 콜백을 실행하는 값에만 의미가 있음.
**오늘 두 문맥의 판정값은 우연히 같다**(둘 다 `isBoundAlive` 하나로
귀결 — gcconn이 살아있는가 OR `.Subscribed`인가). 다섯 번째 세션은 이
우연한 일치를 "애초에 같은 질문"으로 결론지어 하나로 합쳤지만, 호출부가
왜 그 질문을 묻는지는 서로 다름 — `Ref`처럼 발화라는 개념 자체가 없는
값에게 "발화해도 되는가"(`canExecute`)를 묻는 건 개념이 안 맞고, 나중에
"구조적으로는 묶여 있지만 일시적으로 발화만 멈춘" 상태가 생기면(지금은
없음) `canBound`는 참인데 `canExecute`는 거짓이어야 하는 경우도 생길 수
있음 — 판정값이 갈라질 여지 자체가 원래 있었다는 뜻.
**해법 — 이름은 둘, 판정 로직은 하나(사용자 제안).** 실제 gcconn/
`.Subscribed` 체크는 비공개 헬퍼 `isBoundAlive(value)`(위 (1) 코드
블록) 하나에만 있고, `canBound`/`canExecute`는 둘 다 그 헬퍼를 그대로
호출하는 얇은 진입점 — 코드 중복 없이 호출부의 의미만 분리됨. **바뀐
호출부**: `bindLifetime`의 가드(위 (1))와 `Observer:Subscribe()`
가드(위 (2))는 이제 `canBound`를 씀 — `canExecute`를 쓰던 옛 코드에서
이름만 바뀜, 동작은 동일. **안 바뀐 호출부**: State 전파 루프(아래
"(4)")만 여전히 `canExecute`를 씀.
부수 효과(다섯 번째 세션 결론과 값은 동일, 이름만 갈라짐): **"바인딩이
죽은 뒤의 재사용은 허용"** — `inst`가 Destroy됐거나 `unbindLifetime`
`value``canBound`가 거짓이라 게이트를 통과함(다시 다른 `inst`에 걸
수 있음). 살아있는 바인딩만 막는 게 이 게이트의 의도.
#### (4) 실제 호출부 — State 전파(`emit`)가 `canExecute`로 게이팅한다

View file

@ -244,7 +244,7 @@ UB로 남겨둠")은 폐기. 재검토 근거(사용자): Modifier는 애초에
같은 걸 다루는 목적이 아니고, 이런 값이 실제로 쓸모 있는 use case가
없다고 확인된 이상 조용한 UB보다 그 자리에서 막는 쪽이 낫다 — 판별
비용도 이미 있는 `Brand` 기반 predicate(`isRef`/`isPreRef`/`isPostRef`/
`isObserver`/`isEffect`/`isSlot`/`isModifier`, `bind-system-plan.md`의
`isObserver`/`isEffect`/`isSlot`/`isModifier`, `brand-plan.md`의
`Brand` 절)를 그대로 재사용하면 되므로 거의 공짜.
- **체크 지점 — 제네릭 `__index` setter가 최종 저장 직전에 검사.**
@ -596,7 +596,7 @@ setter 표면과 read 표면이 헷갈리고, 타이핑 이득도 메소드 방
안 되는 캐비엇이 있지만, 이건 quad가 대신 풀어줄 문제가 아니라 문서화
(경고)로 충분(이미 있는 "`Get()` 결과 캐싱 금지" 캐비엇과 같은 클래스).
**`isState(x): boolean` 필요 — `base/bind-system-plan.md`에 정의**.
**`isState(x): boolean` 필요 — `base/brand-plan.md`에 정의**.
`Peek`가 raw union을 돌려주므로 사용자 코드가 State/plain을 분기하려면
판별 수단이 필요함(Source가 State를 구조적으로 만족하므로 `isState`
Source도 같이 잡아줌 — **[2026-08-07 여덟 번째 세션 정정] `isSource`

View file

@ -134,10 +134,17 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류
`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
유틸은 인터페이스, 실제 구현은 백엔드 팩토리가 주입" 절 참고. **중복 호출
소유하는 핸들러와 주입되는 엔진 op" 절). **`TagHandler`/
`AttributeKeyHandler`/`AttributeGroupHandler` 자신은 quad-base가
모듈 로드 시점에 `HANDLER_PRIORITY_FALLBACK`으로 스스로 등록** —
`addTag`/`removeTag`/`setAttribute`만 백엔드 팩토리가 뮤테이션으로
채우는 타입 계약. 아직 아무 팩토리도 안 채운 슬롯의 기본값은
quad-base가 명시적으로 에러내는 스텁으로 미리 채워둠(조용한 no-op
추측 아님 — base가 임의 엔진의 "맞는 기본 동작"을 알 수 없어서).
더 명확한 메시지나 진짜 원자적 실패(부기 mutation 0회)를 원하는
백엔드는 opt-in으로 `HANDLER_PRIORITY_FALLBACK + 1`짜리 가로채기
Handler를 추가로 등록할 수 있음 — 상세는
`base/dispatch-core-plan.md`의 같은 절. **중복 호출
가드/`New()`와의 관계는 2026-08-04 3차 라운드에서 확정**: 같은 팩토리로
재호출하면 무시(no-op), 다른 팩토리로 재호출하면 에러(유일 슬롯 충돌 —
바로 위 "Bind는 누가, 어떻게 구현하는가" 절의 원칙과 일치) — `New()`

View file

@ -259,6 +259,8 @@ RefLeafHandler.isHandlable(inst, k, v) = isRef(v) and not isPreRef(v)
function RefLeafHandler.process(inst, k, v, index)
local old = relate:GetStrong(inst, k)
if old ~= v then -- 이미 같은 Ref가 이 자리를 차지 중이면 재통지 skip
bindLifetime(inst, v) -- v가 이미 다른 자리에 살아있으면 여기서 즉시 error —
-- 이중 배치 방지("이중 배치 방지" 절 참고), 별도 Relate 불필요
v:Set(inst)
end
relate:SetStrong(inst, k, v)
@ -266,6 +268,7 @@ function RefLeafHandler.process(inst, k, v, index)
-- nextValue는 nil이거나 같은 핸들러가 곧 처리할 새 Ref(타입 보장됨) — v는
-- 이 process 호출이 만든 클로저가 직접 캡처(Relate 재조회 불필요)
if nextValue ~= v then
unbindLifetime(v) -- 점유 해제 — 이후 v는 다른 자리에 다시 bindLifetime 가능
v:Set(nil) -- 매 :Set()마다 콜백 재통지되는 기존 Ref 규칙(위 "해소됨 —
-- 반복 재설정 가능" 항목)을 그대로 재사용, 새 알림 경로 아님
-- [정정, 2026-08-13 감사] relate 정리는 반드시 이 분기 *안*에 있어야
@ -311,6 +314,40 @@ end
이벤트 안에 로직을 두는 기존 관례)를 쓰도록 문서가 유도할 것 — Ref
자신에 Destroy-awareness를 얹는 건 오버엔지니어링.
### 이중 배치 방지 — `question.md` 0-W 해소, (a) 선택 (2026-08-14 열한 번째 세션)
**같은 `Ref` 객체를 두 자리에 동시에 놓으면 뒤에 놓은 자리가 앞 자리의
바인딩을 조용히 지우는 문제**(`Frame1{r}`/`Frame2{r}`처럼 같은 `r`을 두
Frame의 children 배열에 각각 리터럴로 놓으면 — `Ref`는 항상 children
배열 아이템으로 놓이므로 `k`는 문자열 `"Ref"`가 아니라 그 자리의 배열
인덱스(숫자) — `r:Set(inst1)` 다음 `r:Set(inst2)`가 에러 없이 덮어씀,
`inst1` 자리가 나중에 retract되면 `r:Set(nil)``inst2`의 정당한 값까지 지움)를
**즉시 error로 막기로 확정** — `Slot``claimOwner`, `PreRef`/`PostRef`의
`_fired`, `Attribute`의 이름 claim과 같은 급의 방어를 `Ref`에도 채택.
**메커니즘 — 새 `Relate`를 안 만들고 `bindLifetime`/`unbindLifetime`을
그대로 재사용.** `bindLifetime(inst, value)`은 이미 자기 내부에 "이 value가
이미 다른 곳에 살아있는 바인딩을 갖고 있으면 즉시 error"라는 가드를 갖고
있음(`base/lifecycle-pattern.md`의 `canBound` 게이트) — `Ref`가 바인딩될
때마다 이 가드를 그대로 통과시키면 이중 배치가 저절로 막힘. 위
`RefLeafHandler.process``bindLifetime(inst, v)`/`unbindLifetime(v)` 호출이
그것 — 실제 바인딩이 일어나는 분기(`old ~= v`)에서만 걸어서 spurious
재발행(같은 `v`가 다시 오는 경우)엔 안 걸림, 실제 언바인딩이 일어나는
분기(`nextValue ~= v`)에서만 풀어서 그 뒤 다른 자리에 재바인딩 가능.
**기존 dedup용 `relate`와는 별개 관심사** — `relate`는 "이 슬롯에 마지막으로
뭐가 있었는지"(spurious 재발행 dedup)를 기억하고, `bindLifetime`은 "이 `Ref`
객체가 지금 어딘가에 살아있게 물려 있는지"(이중 배치 방지)를 판정함. 서로
다른 축이라 하나가 다른 하나를 대체 못 함 — 계속 둘 다 필요.
**children 배열 리터럴 경로도 같은 코드를 그대로 타므로 자동으로 커버됨**
(`Frame1{r}`/`Frame2{r}`가 원래 문제였던 그 케이스) — 리터럴 구성은
`old`가 항상 `nil`이라 매번 `bindLifetime`이 불리고, 두 번째 자리에서
`r`이 이미 살아있는 바인딩을 갖고 있으니 그 즉시 에러.
`question.md`의 원 형제 프리미티브 대조 표(`Ref` 행 "없음")는 해소로 갱신,
상세는 `archive/question-resolved.md`.
### `phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설 (2026-08-07 세 번째
세션 — 이 절이 당시 쓰던 `CreatedRef(fn, ...)` 래퍼 이름 자체도 이후
아홉 번째 세션에서 폐기됨, 위 "바인드 방법" 절 참고)
@ -533,10 +570,20 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
값으로 막는 이유" 절은 **타입 차단**만 다뤘음 — Luau 타입은 런타임에
지워지므로(`:Peek`/`Overridden`/버그로 타입을 우회해 PreRef가 Modifier나
Store 값으로 실제로 흘러들어오는 경우), 런타임에도 방어가 필요함.
전용 `Handler`를 하나 등록: `{ isHandlable = function(inst,k,v) return
isPreRef(v) end, process = function(inst,k,v) error("PreRef는 children
배열 리터럴에만 놓을 수 있음") end }` — `NoneHandler`와 같은 결의
"한 값 종류만 전담하는 Handler" 패턴 재사용, 새 메커니즘 아님. 이
전용 `Handler`를 하나 등록: `{ priority = HANDLER_PRIORITY_FALLBACK,
isHandlable = function(inst,k,v) return isPreRef(v) end, process =
function(inst,k,v) error("PreRef는 children 배열 리터럴에만 놓을 수
있음") end }` — `k` 타입은 안 가림(숫자든 문자열이든 `isPreRef(v)`
보고 매치). `NoneHandler`와 같은 결의 "한 값 종류만 전담하는 Handler"
패턴 재사용, 새 메커니즘 아님. **[2026-08-14 열한 번째 세션] 우선순위는
`HANDLER_PRIORITY_FALLBACK`**(무조건 매치하는 하드 블록이 아니라
`Tag`/`Attribute`와 같은 "base가 소유하지만 백엔드/특정 자리에서
평범한 우선순위로 자기 핸들러를 등록하면 덮어쓸 수 있는" 자리 —
지금은 그 자리를 아무도 안 가져가서 항상 이 가드가 매치돼 에러가
나지만, 나중에 named 자리 바인드 같은 실제 기능이 확정되면 base
가드를 건드리지 않고 그 기능의 Handler를 평범한 우선순위로 하나
등록하는 것만으로 자연히 우선함, `base/dispatch-core-plan.md`
"`HANDLER_PRIORITY_FALLBACK`" 절). 이
Handler는 **`Dispatch.process`/`getHandler`의 정상 우선순위 스캔에
등록**되는 반면(pre-pass처럼 그 밖에서 도는 게 아님), 리터럴 배열의
`PreRef`는 pre-pass가 fire와 동시에 해당 슬롯을 소진(**[정정,
@ -718,9 +765,12 @@ dispatch-core-plan.md` "Length/Offset" 절의 계약을 특수 취급 없이 그
동일: flatten되면 해시 파트로 존재하게 돼 "배열 파트" 전제를 벗어나고,
Store 경로로 뒤늦게 도착한 값은 "이 인스턴스의 construction 훅"이라는
정의 자체를 만족시킬 수 없음). 타입은 런타임에 지워지므로 정상 우선순위
레지스트리에 `{ isHandlable = isPostRef(v), process = error("PostRef는
children 배열 리터럴에만 놓을 수 있음") }` Handler를 등록 — pre-pass가
이미 소진시키므로 이게 매치되면 곧 타입 차단을 우회한 버그라는 뜻.
레지스트리에 `{ priority = HANDLER_PRIORITY_FALLBACK, isHandlable =
isPostRef(v), process = error("PostRef는 children 배열 리터럴에만 놓을
수 있음") }` Handler를 등록(`k` 타입 안 가림 — `PreRef`의 "동적 경로
가드" 절과 완전히 같은 이유로 `HANDLER_PRIORITY_FALLBACK`, 2026-08-14
열한 번째 세션) — pre-pass가 이미 소진시키므로 이게 매치되면 곧 타입
차단을 우회한 버그라는 뜻.
**1회용, 재사용은 즉시 error** — `PreRef`와 같은 `_fired` 플래그를 그대로
재사용(위 "PreRef는 '취소'라는 개념이 없다" 절과 같은 근거: 이미 fire된

View file

@ -897,6 +897,23 @@ retract/Destroy되면 자동으로 정리됨.
`Ref`와 같은 방식으로 라이프사이클에 묶어주는 것 말고는 base가
더 해줄 일이 없음. 새 dispatch 메커니즘이 아니라 기존 children-array
참가자 패턴의 반복.
- **동적 경로 가드 — `k` 무관 매치, `HANDLER_PRIORITY_FALLBACK`
(2026-08-14 열한 번째 세션, `PreRef`의 동적 경로 가드와 같은 패턴).**
`Observer`도 children 배열 리터럴 전용이라, 해시 파트 named 자리
등으로 동적으로 흘러들어오면(타입 우회 버그) 명확히 에러내야 함 —
전용 `Handler` 등록: `{ priority = HANDLER_PRIORITY_FALLBACK,
isHandlable = function(inst,k,v) return isObserver(v) end, process =
function(inst,k,v) error("Observer는 children 배열 리터럴에만 놓을 수
있음") end }`. `HANDLER_PRIORITY_FALLBACK`인 이유는 이게 무조건 막는
하드 블록이 아니라 `Tag`/`Attribute`/`PreRef`와 같은 "base가 소유하되
평범한 우선순위로 등록된 다른 Handler가 있으면 그쪽이 이기는" 자리이기
때문(`base/dispatch-core-plan.md`의 "`HANDLER_PRIORITY_FALLBACK`" 절) —
지금은 아무도 그 자리를 안 가져가서 항상 이 가드가 에러를 내지만, 이
Handler를 만드는 게 목적이 아니라 "지금은 확정된 기능이 없다"는 default를
base가 값싸게 제공하는 것뿐. (**이 가드가 없던 이전엔** 확정된 "매치
실패는 즉시 error" 규칙에 의해 결과적으로 똑같이 에러가 났었음 — 이
가드는 동작을 바꾸는 게 아니라 에러 메시지를 명확하게 하고, 미래에
override할 자리를 구조적으로 열어두는 것.)
- **콜백 실행은 기존 `canExecute` predicate로 게이팅**(Slot 생존 확인과
동일한 재사용 — "canExecute 하나로 통일" 원칙, 새 메커니즘 발명 아님)
— 발화 시점과 처리 시점 사이에 owning leaf가 이미 죽었으면 no-op.
@ -1023,7 +1040,7 @@ no-op. 한때 검토했던 "`isInit=false`면 허용, `isInit=true`+생존확인
객체를 mutate하고 그대로 돌려주는 것)지만 표면 문법은 비슷하게
체이닝 가능.
## 이중 바인딩 금지 — 진짜 독립된 경로는 `:Subscribe()`(전역)와 `bindLifetime`(inst-scoped) 둘뿐, `canExecute(value)`로 즉시 에러 (2026-08-07 일곱 번째 세션, 2026-08-09 세션에서 `canBound`로 이름 확정, 같은 날 여섯 번째 세션에서 "leaf 부착=bindLifetime 호출"로 정정, **2026-08-14 다섯 번째 세션에 `canBound` 폐기·`canExecute`로 통합**)
## 이중 바인딩 금지 — 진짜 독립된 경로는 `:Subscribe()`(전역)와 `bindLifetime`(inst-scoped) 둘뿐, `canBound(value)`로 즉시 에러 (2026-08-07 일곱 번째 세션, 2026-08-09 세션에서 `canBound`로 이름 확정, 같은 날 여섯 번째 세션에서 "leaf 부착=bindLifetime 호출"로 정정, 2026-08-14 다섯 번째 세션에 `canBound` 폐기·`canExecute`로 통합됐다가 **같은 날 열한 번째 세션에 `canBound`가 별도 진입점으로 재도입되어 다시 갈라짐** — 판정 로직은 공유, `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절이 소스)
**규칙**: 같은 Observer/Effect 핸들 하나는 라이프사이클 바인딩 경로를
딱 하나만 가질 수 있음 — `:Subscribe()`로 전역 강참조 레지스트리에
@ -1055,31 +1072,36 @@ leaf 부착을 "weak table 기반 자동 추적"이라 불렀던 건 `bindLifeti
0(불리언 필드 하나 확인)이라, 조용히 이상하게 동작하게 두는 것보다
바로 에러를 던져 버그를 그 자리에서 잡는 게 엔지니어링상 훨씬 쌈.
**[역전, 2026-08-14 다섯 번째 세션] 별도 predicate `canBound(handle)`
폐기하고 `canExecute(value)` 하나로 통합.** 게이트는 이 모양:
**[2026-08-14 다섯 번째 세션에 별도 predicate `canBound(handle)`을 폐기하고
`canExecute(value)` 하나로 통합했다가, 같은 날 열한 번째 세션에
`canBound`가 다시 별도 진입점으로 도입됨]** — `Ref`가 emit 전파에 참여도
안 하면서 "발화해도 되는가"(`canExecute`)를 묻는 게 개념적으로 안 맞다는
지적(`question.md` 0-W)에서 나온 재분리. 게이트는 이 모양:
```lua
-- :Subscribe() 진입부, bindLifetime 진입부(leaf 부착도 내부적으로 이걸 거침)
-- — 둘 다 진입 전 동일하게 확인
if canExecute(self) then
if canBound(self) then
error(if self.Subscribed
then "이미 :Subscribe()로 전역 바인딩된 값"
else "이미 다른 Instance에 바인딩된 값")
end
```
- **"이미 유효하게 묶여 있다"와 "지금 실행 가능하다"가 정확히 같은
조건**이라 predicate를 둘로 나눌 이유가 없었음 — `canExecute`
참이면 그 값은 어딘가에 살아있는 바인딩을 갖고 있다는 뜻이고, 그게
곧 "새로 묶으면 안 된다"임.
- **"이미 유효하게 묶여 있다"(`canBound`)와 "지금 실행 가능하다"
(`canExecute`)는 판정 로직이 같아서**(둘 다 비공개 헬퍼
`isBoundAlive`를 그대로 부름, `base/lifecycle-pattern.md`) 값은 항상
같지만, 호출부의 질문이 서로 달라 이름은 분리돼 있음 — 이 절(이중
바인딩 금지)은 `canBound`를 쓰고, State emit 전파 루프만 `canExecute`
씀.
- **에러 메시지에서 어느 경로인지는 `.Subscribed`로 가름** — 이 필드는
**전역 `:Subscribe()` 경로에서만 세팅되므로**(아래 정정) 참이면 전역,
거짓인데 `canExecute`가 참이면 leaf 경로.
거짓인데 `canBound`가 참이면 leaf 경로.
- 이 predicate는 어느 경로가 먼저 왔는지와 무관하게 "이미 유효한
바인딩이 있음"만 답함 — 두 진입점이 똑같이 `canExecute`를 확인하므로
바인딩이 있음"만 답함 — 두 진입점이 똑같이 `canBound`를 확인하므로
순서와 무관하게 대칭적으로 막힘.
- **죽은 바인딩의 재사용은 허용**`inst`가 Destroy됐거나
`unbindLifetime`된 값은 `canExecute`가 거짓이라 게이트를 통과함(다른
`unbindLifetime`된 값은 `canBound`가 거짓이라 게이트를 통과함(다른
`inst`에 다시 걸 수 있음). 게이트가 막는 건 **살아있는** 이중 바인딩뿐.
**[정정, 2026-08-14 다섯 번째 세션] 옛 서술 — "`canBound`의 내부 플래그는
@ -1090,9 +1112,11 @@ end
그 필드를 읽지도 쓰지도 않음. leaf 경로의 생존은 `bindLifetime`
`value` 쪽 릴레이션에 복사해둔 gcconn 참조로 판정됨(`base/lifecycle-pattern.md`).
옛 서술이 걱정했던 "필드를 둘로 나누면 `bindLifetime`으로만 등록된
Observer가 `canExecute`에서 항상 `false`로 오판됨"은 실제로는 안 일어남
`canExecute`가 gcconn 경로를 **먼저** 보기 때문. 역전 원문·오염 경로·
교훈은 `archive/canexecute-inst-arg-reversed.md`.
Observer가 `canBound`에서 항상 `false`로 오판됨"은 실제로는 안 일어남
`canBound`(와 `canExecute`가 공유하는 `isBoundAlive`)가 gcconn 경로를
**먼저** 보기 때문. 역전 원문·오염 경로·교훈은
`archive/canexecute-inst-arg-reversed.md`(그 문서 하단에 이 재분리
경위도 추가돼 있음).
- **`:Unsubscribe()``:Subscribe()` 경로의 해제만 담당, `bindLifetime`
(leaf 부착 포함) 경로는 `unbindLifetime(value)`로 해제** —
둘은 서로 다른 함수로 남음(호출자가 `bindLifetime`을 부른 쪽이
@ -1106,7 +1130,7 @@ Observer가 `canExecute`에서 항상 `false`로 오판됨"은 실제로는 안
`unbindLifetime`이 leaf 해제의 실제 통로).
- **Effect도 동일 규칙 적용(사용자 확인)** — Effect가 `state` 인자로
내부적으로 Observer를 조합하는 경우든, `state` 없는 경우든 같은
`canExecute` 게이트를 그대로 재사용(`base/effect-plan.md`) — Effect
`canBound` 게이트를 그대로 재사용(`base/effect-plan.md`) — Effect
자신이 아니라 내부 Observer가 게이트를 갖고 있어서, Effect 구현이
이 정정을 몰라도 자동으로 커버됨. 이전에 그 문서에 적어뒀던 "leaf
부착과 `:Subscribe()`를 동시에 쓰는 것도 안전"이라는 서술은 **이

View file

@ -168,9 +168,12 @@ end
일치를 검사**하므로 문제없이 `Source<string>` 자리에 대입 가능 — 오히려 이
방식과 정확히 맞는 조합. 이걸로 `store.key`가 실제로 타입 명시 가능함이
확인돼 M0/M3 어느 시점에 검증해도 기술적으로 막힐 위험은 없음 —
`ROADMAP.md`의 M0/M3 배치를 강제로 바꿀 필요는 없어짐, 검증 난이도
문제였던 것만 해소. (이 방식이 못 해주는 것은 `base/typing-limits.md`
따로 정리.)
`ROADMAP.md`의 M0/M3 배치를 강제로 바꿀 필요는 없어짐, 설계 레벨의 검증
난이도 문제였던 것만 해소. **단 이 `type function` 접근 자체의 실측은
아직 미완료** — 스파이크(`luau-test/rewrite-required/16-type-store-key-
typefunction.luau`)는 `types.newfunction` 시그니처 불일치로 깨져 재작성
대기 중(`luau-test/STATUS.md` 소스). 실측 전까지는 설계 확정이지 검증
완료가 아님(`base/typing-limits.md`가 이 구분을 명확히 다룸).
## Store가 Store를 저장 가능한가

View file

@ -152,7 +152,7 @@ identity로 홀더를 추적하면 같은 객체를 두 위치(`k1`, `k2`)에
```lua
local tagNameMap = Relate() -- {[inst(weak)] = {[tagName]: {[k]: true}}} — 이름별 현재 걸고 있는 위치들
TagHandler.priority = <일반>
TagHandler.priority = HANDLER_PRIORITY_FALLBACK
TagHandler.isHandlable(inst, k, v) = isTag(v) -- Brand 기반, array-part 전용
function TagHandler.process(inst, k, v, index)
@ -264,12 +264,19 @@ removeTag(inst: any, names: {string}): ()
vararg → `string | {string}`으로 되돌아갔던 것과 **같은 이유**(위 "값
모양" 절). 배치 호출 자체는 테이블로도 되므로 웹 `className` 일괄
갱신 요구도 그대로 충족됨.
- **등록 우선순위는 `HANDLER_PRIORITY_FALLBACK`** — 특정 백엔드가 태그
처리를 통째로 다르게 하고 싶으면 평범한 우선순위로 자기 핸들러를
등록하면 자동으로 이김. 주입 op이 없는 백엔드에서는 base 스텁이
명확한 에러를 냄. 일반 원칙과 근거는 `base/dispatch-core-plan.md`
"base가 소유하는 핸들러와 주입되는 엔진 op" 절 — `Attribute`도 정확히
같은 구조(`base/attribute-plan.md`).
- **등록 우선순위는 `HANDLER_PRIORITY_FALLBACK``TagHandler` 자신은
quad-base가 모듈 로드 시점에 스스로 등록**(2026-08-14 열한 번째 세션
재확인 — 한때 "등록 자체가 백엔드 선택"으로 잘못 정정됐다가 철회됨).
`addTag`/`removeTag`만 백엔드 팩토리가 채우는 타입 계약, 안 채운
슬롯의 base 기본값은 명시적으로 에러내는 스텁. 더 명확한 메시지나
진짜 원자적 실패를 원하는 백엔드는 opt-in으로
`HANDLER_PRIORITY_FALLBACK + 1`짜리 가로채기 Handler를 추가로 등록
가능. 태그 처리 자체를 통째로 다른 알고리즘으로 바꾸고 싶은 백엔드는
`HANDLER_PRIORITY_FALLBACK`보다 확실히 높은 우선순위로 자기 Handler를
등록하면 base `TagHandler`를 완전히 대체함(같은 override 원리).
상세는 `base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와
주입되는 엔진 op" 절 — `Attribute`도 정확히 같은 구조
(`base/attribute-plan.md`).
- 이건 새 아키텍처 개념이 아니라 이미 확정된 "base는 인터페이스/값,
backend는 구현"(`LifetimeHandle`의 `bindLifetime`/`canExecute`,
`Dispatch.addHandler` 자체가 그 패턴)을 핸들러 층까지 밀어붙인 것.

View file

@ -250,7 +250,7 @@ RFC가 순수 내부 변경이고 우리 선언이 이미 그 대상 모양이
아래는 **실측으로 통과 확인**된 것들이라 다시 의심하지 말 것:
- **`Source<T>``State<T>`를 구조적으로 만족**(서브타입으로 그대로
넘길 수 있음) — `luau-test/review-required/08`. 단 **단방향 의존을
넘길 수 있음) — `luau-test/done/08`. 단 **단방향 의존을
유지해야 함**(`State<T>`가 `Source`를 참조하면 안 됨 — 두 제네릭
별칭의 상호 재귀는 솔버가 취약한 패턴).
- **`PreRef<T>``Ref<T>` 자리에 대입 가능** — `luau-test/13` A섹션.

View file

@ -11,8 +11,8 @@
| 폴더 | 뜻 | 누가 처리 |
|---|---|---|
| `review-required/` | **설계가 걸림 — 사람 결정 필요**(**[2026-08-13 13차 세션] 현재 비어 있음** — 마지막 한 건이던 `08`이 해소돼 `done/`으로 감) | ⭐ 사용자 |
| `rewrite-required/` | 스파이크가 낡음 — 코드가 깨졌거나(`13`/`15`/`16`), **설계가 바뀌어 옛 모델을 검증 중**(`04`/`19`, 2026-08-13 14차 세션 하강 diff / `10`, **[2026-08-14 3차 세션]** `canExecute` 1-인자 재정정) | 에이전트 |
| `not-run/` | 이 환경에서 못 돌림 — **[2026-08-14 3차 세션] 스파이크는 0건**(`10`이 `rewrite-required/`로 감), GC 헬퍼만 남음 | 사용자 or MCP 연결 후 |
| `rewrite-required/` | 스파이크가 낡음 — 코드가 깨졌거나(`13`/`15`/`16`), **설계가 바뀌어 옛 모델을 검증 중**(`04`/`19`, 2026-08-13 14차 세션 하강 diff / `10`, **[2026-08-14 5차 세션]** `canExecute` 1-인자 재정정 / `05`, **[2026-08-14 8차 세션]** "emit은 항상 전파" 정정) | 에이전트 |
| `not-run/` | 이 환경에서 못 돌림 — **[2026-08-14 5차 세션] 스파이크는 0건**(`10`이 `rewrite-required/`로 감), GC 헬퍼만 남음 | 사용자 or MCP 연결 후 |
| `done/` | 통과 or 판정 끝, 더 할 일 없음 | — |
**스파이크를 고치거나 돌렸으면 파일을 해당 폴더로 `git mv`하고 STATUS.md의
@ -76,7 +76,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
| `07-relate-weak-table-gc.luau` | `Relate`의 lazy 서브테이블 생성 + weak-key GC가 실제로 동작하는지 | `relate-plan.md` "M2 착수 시 실측 확인" **[2026-08-13 보강]** 4번 섹션 신설 — `_countEntries()`(테스트 전용) + weak-value canary로 **"inst가 죽으면 중첩 StrongMap 안의 payload까지 연쇄 GC되는가"를 직접 검증**(원래는 sanity check만 하고 헤더의 핵심 주장은 미검증이었음). 파일이 스스로 적어둔 "weak table 엔트리를 셀 표준 API가 없다"는 전제도 틀렸음 — outer가 `__mode="k"`라 GC 후 `pairs`에서 사라짐 |
| `08-type-source-satisfies-state.luau` (타입체크 전용) | `Source<T>``State<T>`를 구조적으로 만족하는 제네릭 타입이 솔버에서 안전한지 | `base/source-state-plan.md` "Source가 State를 만족함", ROADMAP M0-2 |
| `09-type-modifier-overridden-subtype.luau` (타입체크 전용) | `FrameModifier <: GuiObjectModifier`처럼 서브타입 관계인 Modifier를 `Overridden`으로 섞을 때 타입이 통과하는지 | `modifier-plan.md` 9-2번, ROADMAP M7 |
| `10-roblox-studio-checks.server.luau` (Studio 전용) | **[⚠️ 2026-08-14 다섯 번째 세션: A 섹션이 폐기된 모델을 검증 중 → `rewrite-required/`]** (A) `bindLifetime`/`unbindLifetime`/`canExecute`의 gcconn 트릭 + 이중 바인딩 게이트(Destroy 시 Connected 전환 포함), (B) Attribute의 Instance 참조 타입 지원, (C) CollectionService 태그/GetTagged 왕복. **A는 재작성 대상** — 파일 속 `canBound`, `bindLifetime``value.Subscribed = true` 세팅, 2-인자 `canExecute(inst, value)`는 전부 폐기됨(게이트는 `if canExecute(v) then error(...) end` 하나, `canExecute``value` 단독 1-인자, gcconn/gchold는 **Instance 생성 시점**에 생성). **[2026-08-13]** A 섹션 앞부분(ClassName 신호 미발화, Destroy 시 Connected 즉시 전환)은 사용자 자작 스크립트로 부분 확인됐고 **새 모델에서도 그대로 유효**(오히려 더 중요 — `canExecute``.Connected`를 직접 읽는 게 leaf 경로 판정의 전부), `audit/gcconn-trick-verification.md` 참고. 이중 바인딩 게이트/재바인딩 허용/B/C는 이 공식 파일로 아직 확인 안 됨 | `lifecycle-pattern.md` "`bindLifetime`/`canExecute`/`unbindLifetime` — 확정", `archive/canexecute-inst-arg-reversed.md`, `source-state-plan.md` "이중 바인딩 금지", CLAUDE.md 2026-08-06 세션, `debug-tooling-plan.md` |
| `10-roblox-studio-checks.server.luau` (Studio 전용) | **[⚠️ 2026-08-14 다섯 번째 세션: A 섹션이 폐기된 모델을 검증 중 → `rewrite-required/`, 열한 번째 세션에 `canBound` 재도입으로 재작성 사유 하나 더 추가]** (A) `bindLifetime`/`unbindLifetime`/`canBound`/`canExecute`의 gcconn 트릭 + 이중 바인딩 게이트(Destroy 시 Connected 전환 포함), (B) Attribute의 Instance 참조 타입 지원, (C) CollectionService 태그/GetTagged 왕복. **A는 재작성 대상** — 파일 속 `canBound`(9차 세션 정의)와 `bindLifetime``value.Subscribed = true` 세팅, 2-인자 `canExecute(inst, value)`는 전부 낡음(현재 게이트는 이중 바인딩 확인은 `canBound(v)`, emit 게이팅은 `canExecute(v)` — 둘 다 `value` 단독 1-인자로 비공개 헬퍼를 공유, gcconn/gchold는 **Instance 생성 시점**에 생성). **[2026-08-13]** A 섹션 앞부분(ClassName 신호 미발화, Destroy 시 Connected 즉시 전환)은 사용자 자작 스크립트로 부분 확인됐고 **새 모델에서도 그대로 유효**(오히려 더 중요 — `canBound`/`canExecute`가 `.Connected`를 직접 읽는 게 leaf 경로 판정의 전부), `audit/gcconn-trick-verification.md` 참고. 이중 바인딩 게이트/재바인딩 허용/B/C는 이 공식 파일로 아직 확인 안 됨 | `lifecycle-pattern.md` "`bindLifetime`/`canBound`/`canExecute`/`unbindLifetime` — 확정", `archive/canexecute-inst-arg-reversed.md`, `source-state-plan.md` "이중 바인딩 금지", CLAUDE.md 2026-08-06 세션, `debug-tooling-plan.md` |
| `11-modifier-illegal-value-error.luau` | Modifier 필드에 Ref/PreRef/Observer/Effect/Slot/Modifier가 들어오면 즉시 error, State/Source가 확정하는 값이 Modifier면 즉시 error(2026-08-09 세션에 "UB"에서 전환된 규칙) | `modifier-plan.md` "핸들러 계층 값 즉시 error" 절 + 7번 절 |
| `12-type-attribute-generic-key-narrowing.luau` (타입체크 전용) | `[AttributeKey<<T>> "name"] = value`(구 `Attribute<<T>>`)처럼 제네릭 DI 키를 쓸 때 `value`의 타입이 실제로 `T`로 좁혀지는지 — base 문서 자신이 "미검증"이라 명시한 항목 | `attribute-plan.md` "[실측 필요, M0/M10]" (2026-08-09 열한 번째 세션 신설) |
| `13-type-ref-preref-subtype.luau` | (A, 타입) `PreRef<T>``Ref<T>`를 구조적으로 만족하는지, (B, 런타임) `isRef`/`isPreRef` 합성이 재정정대로 동작하는지(`isRef(preRefInstance)`가 이제 `true`) + Leaf 핸들러가 `isRef(v) and not isPreRef(v)`로 명시적으로 좁혀야 하는 이유. **[2026-08-14 아홉 번째 세션] 재작성 시 `PostRef`도 같이 커버할 것** — 같은 `Ref` 런타임 재사용 + 브랜드 태그만 다른 형제라 A/B 둘 다 그대로 확장되고, Leaf predicate도 `isRef(v) and not isPreRef(v) and not isPostRef(v)`로 늘어남 | `brand-plan.md``Brand` 절(2026-08-09 열한 번째 세션 재정정) |
@ -211,7 +211,7 @@ error)를 추가하고, `04`의 3~4단계를 "가드가 실제로 걸리는지 +
대조해야 판정 가능 — 별도 진행. `15``SyntaxError`로 파싱 자체가
안 되는 상태(= 아무것도 검증 못 함)인 것만 먼저 확정.
**12차 (2026-08-14, 번째 세션, `10``rewrite-required/`)**:
**12차 (2026-08-14, 다섯 번째 세션, `10``rewrite-required/`)**:
`bindLifetime`/`canExecute`/`unbindLifetime` 시그니처가 재정정되면서
(`canExecute`/`unbindLifetime`이 `inst`를 안 받는 1-인자, `.Subscribed`
전역 `:Subscribe()` 전용이라 `bindLifetime`과 무관, 별도 predicate
@ -239,16 +239,18 @@ Connected 즉시 전환"은 새 모델에서도 유효**(재작성 시 살릴
재검토해야 하는 심각한 발견이니 바로 알려줄 것 — **[2026-08-13] 이
조건은 이미 회피 확인됨**(`audit/gcconn-trick-verification.md`),
재확인 불필요.
- 이중 바인딩 게이트는 이제 `canBound`가 아니라 **`canExecute(value)`
하나**다(`if canExecute(v) then error(...) end`). 판정 기준도 이에
맞춰 바뀜: `unbindLifetime(value)` 이후 같은 값을 다시
`bindLifetime`할 수 있어야 하고(게이트가 `canExecute` 거짓이라
통과), **`inst`가 Destroy된 뒤의 재바인딩도 이제 명시적으로
허용**임(살아있는 바인딩만 막는 게 게이트의 의도 —
`lifecycle-pattern.md` "`canBound` 폐기" 절). 이게 실패하면
`canExecute` 통합 설계 자체를 재검토해야 함 — **아직 미확인.**
- **[2026-08-14 열한 번째 세션 재정정]** 이중 바인딩 게이트는
**`canBound(value)`**다(`if canBound(v) then error(...) end`) —
`canExecute`는 State emit 전파 게이팅 전용으로 남고, `canBound`
별도 진입점으로 재도입됨(판정 로직은 비공개 헬퍼 하나를 공유,
`lifecycle-pattern.md` "`canBound` vs `canExecute`" 절). 판정
기준은 안 바뀜: `unbindLifetime(value)` 이후 같은 값을 다시
`bindLifetime`할 수 있어야 하고(게이트가 `canBound` 거짓이라
통과), **`inst`가 Destroy된 뒤의 재바인딩도 명시적으로 허용**임
(살아있는 바인딩만 막는 게 게이트의 의도). 이게 실패하면 이
재분리 설계 자체를 재검토해야 함 — **아직 미확인.**
- 새로 검증할 항목: `bindLifetime``value` 쪽 릴레이션에 복사해둔
gcconn만으로 `inst` 생존을 판정할 수 있는가(=`canExecute`가 `inst`
gcconn만으로 `inst` 생존을 판정할 수 있는가(=`canBound`/`canExecute`가 `inst`
없이 성립하는가), gcconn/gchold를 **Instance 생성 시점**에 만들 때
클로저가 `inst`까지 캡처해 userdata 동일성이 유지되는가.
- `04`**[2026-08-13 여섯 번째 세션에 전면 재작성 — 옛 판정 기준 폐기]**

View file

@ -2,7 +2,7 @@
> 마지막 갱신: 2026-08-14 (**"emit은 항상 전파"** 정정으로 `05`가 옛 모델을
> 검증 중이라 `rewrite-required/`로 이동 —
> `archive/invalidate-dedup-propagation-reversed.md`). 직전 갱신은 같은 날 ** 번째 세션**(`bindLifetime`/`canExecute`/
> `archive/invalidate-dedup-propagation-reversed.md`). 직전 갱신은 같은 날 **다섯 번째 세션**(`bindLifetime`/`canExecute`/
> `unbindLifetime` 재정정으로 `10`이 옛 모델을 검증하고 있어
> `rewrite-required/`로 이동 — 이제 `not-run/`에는 스파이크가 없고 헬퍼만
> 남음). 직전 갱신은 2026-08-13 열네 번째 세션(하강 diff 재디스패치 확정으로
@ -68,7 +68,7 @@
| `13-type-ref-preref-subtype.luau` | 타입 A섹션 ✅ 통과 / **런타임 B섹션 실행 불가** | B가 A의 더미 스텁(`fakePreRef = nil`)에 막혀 도달 못 함 — 두 섹션을 파일로 분리 |
| `15-type-compute-trailing-deps-typepack.luau` | **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 |
| `16-type-store-key-typefunction.luau` | ❌ 실패 | `types.newfunction` 시그니처가 설치된 버전의 실제 API와 안 맞음 — 실제 API 재확인 후 재시도 |
| `10-roblox-studio-checks.server.luau` (Studio 전용) | 미실행 + **A 섹션이 옛 모델** | A가 폐기된 `canBound`/`bindLifetime`의 `.Subscribed` 세팅/2-인자 `canExecute`를 검증 중 — **`canExecute(value)` 1-인자 + `bindLifetime`이 gcconn을 `value` 쪽 릴레이션에 복사하는 모델**로 재작성할 것(`base/lifecycle-pattern.md`). 이중 바인딩 게이트도 `canBound`가 아니라 `if canExecute(v) then error(...) end`. **살릴 것**: "ClassName 신호 미발화 / Destroy 시 `Connected` 즉시 전환" 검증(새 모델에서 더 중요해짐), gcconn/gchold를 **Instance 생성 시점**에 만드는 것으로 바꿀 것(옛 lazy 생성 폐기). B/C 섹션은 손댈 것 없음 |
| `10-roblox-studio-checks.server.luau` (Studio 전용) | 미실행 + **A 섹션이 옛 모델** | A가 옛 2-인자 `canExecute(inst,value)``bindLifetime``.Subscribed` 세팅을 검증 중 — **`bindLifetime`이 gcconn을 `value` 쪽 릴레이션에 복사하는 모델**로 재작성할 것(`base/lifecycle-pattern.md`). **[2026-08-14 열한 번째 세션 재정정]** 이중 바인딩 게이트는 `canBound(value)`(`if canBound(v) then error(...) end`) — `canExecute`는 State emit 전파 게이팅 전용으로 분리됨, 둘 다 비공개 헬퍼 `isBoundAlive`를 공유하는 1-인자 진입점(`base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절). **살릴 것**: "ClassName 신호 미발화 / Destroy 시 `Connected` 즉시 전환" 검증(새 모델에서 더 중요해짐), gcconn/gchold를 **Instance 생성 시점**에 만드는 것으로 바꿀 것(옛 lazy 생성 폐기). B/C 섹션은 손댈 것 없음 |
## ⚪ `not-run/` — 이 환경에서 못 돌림

View file

@ -12,70 +12,20 @@
---
## ⭐ 최우선 — **없음** (2026-08-13 열네 번째 세션 기준)
## ⭐ 최우선 — **없음** (2026-08-14 열한 번째 세션 기준)
> **M0 착수를 막던 항목이 전부 해소됐습니다.** `0-Y`(`:Compute(fn)`의
> lazy 핸들 계약)는 열세 번째 세션에, `0-Z`(Attribute 이름 소유권)와
> `0-A`(재디스패치 하강 diff)는 **열네 번째 세션에 확정·`base/` 반영
> 완료**. 해소 전 원문과 결론은 `archive/question-resolved.md`,
> 뒤집힌 옛 재디스패치 모델은 `archive/dispatch-hintvalue-model-reversed.md`.
> `0-A`(재디스패치 하강 diff)는 열네 번째 세션에, **`0-W`(`Ref` 이중
> 배치 방지)는 2026-08-14 열한 번째 세션에 확정·`base/` 반영 완료** —
> "결정 대기" 절 자체가 지금은 비어 있음. 해소 전 원문과 결론은
> `archive/question-resolved.md`, 뒤집힌 옛 재디스패치 모델은
> `archive/dispatch-hintvalue-model-reversed.md`.
>
> **M0 착수 전 읽을 것**: `base/typing-limits.md`(0-Y가 남긴 구현 규약),
> `base/dispatch-core-plan.md`(0-A/0-Z가 반영된 디스패치 코어 — 열네 번째
> 세션에 `bind-system-plan.md`에서 분리 신설).
## 결정 대기 — M0는 안 막음
### 0-W. 같은 `Ref` 객체가 두 자리에 놓이는 걸 막을 것인가 (2026-08-13 열세 번째 세션 신설, 0-Z 확인 중 발견, 2026-08-14 열 번째 세션 기준 M4 구현 세부만 막는 유일한 잔여 항목 — 0-B는 해소돼 `archive/question-resolved.md`로 이전)
**0-Z(Attribute, 열네 번째 세션에 해소됨)를 보다가 "Ref에도 같은 문제가
있냐"는 사용자 질문에서 나온 것 — 있고, 막는 장치가 전혀 없음.** 단
메커니즘은 0-Z와 **반대 방향**이라 별개 항목으로 분리: Attribute는 *두 소유자 → 한 자리*(이름별
메모이즈된 키라 수렴), Ref는 *한 객체 → 두 자리*(발산).
**손 트레이싱**(`base/ref-plan.md`의 `RefLeafHandler` 의사코드에 대입,
`Frame1 { Ref = r }` / `Frame2 { Ref = r }`):
1. `process(inst1,"Ref",r)``relate[inst1]["Ref"]`가 nil → `r:Set(inst1)`
2. `process(inst2,"Ref",r)``relate[inst2]["Ref"]`도 nil(**다른 키**) →
`r:Set(inst2)` — inst1 바인딩이 **조용히 유실, 에러 없음**
3. inst1 자리가 retract → 클로저 인자 `nil ~= v(r)`**`r:Set(nil)`** —
inst2가 정당하게 들고 있던 값을 지움(교차 오염)
`relate``(inst,k)`별로만 있어 "이 Ref가 이미 다른 자리에 있다"를
원천적으로 못 봄.
**형제 프리미티브 대조 — Ref만 비어 있음**:
| | 공유 자원 | 방어 | 상태 |
|---|---|---|---|
| `Slot` | element | `claimOwner`/`claimOwnerAt` → 즉시 error(`Slot{a,a}`/`Frame{slot,slot}`) | 막힘 |
| `PreRef`/`PostRef` | 자기 자신 | `_fired` → 재사용 시 error | 막힘 (`PostRef`는 2026-08-14 아홉 번째 세션 신설, 같은 가드 그대로) |
| `Tag` | 태그 이름 | 위치별 참조 카운트 — 겹침이 **의도된 동작**(합집합) | 설계상 정상 |
| `Attribute` | 이름 | 그룹 전용 키 + 이름 claim → 즉시 error | 막힘(0-Z 해소, 14차 세션) |
| **`Ref`** | 자기 자신 | **없음** | **이 항목** |
특히 걸리는 두 가지:
- **`PreRef`는 정확히 이 재사용을 error로 막고**, 문서가 "`Slot:List`의
`updateFn`처럼 반복 호출되는 자리에선 매번 새 `PreRef()`를 만들라"는
관용구까지 명시해뒀음 — **일반 `Ref`는 같은 자리에서 같은 실수를 해도
아무도 안 막음**(비대칭).
- 스파이크 `19`가 Tag/Attribute/Slot 소유권은 음성 대조군까지 넣어
검증하는데 **`Ref`만 커버가 없음**.
**0-Z와의 관계 — 독립**: 이건 하강 diff 모델이 만든 회귀가 **아니라
원래부터 있던 갭**(옛 점유 체크도 같은 `(inst,k,index)`만 봤지, 서로 다른
자리를 가로지르는 건 원래 안 봤음). **[2026-08-13 열네 번째 세션] 0-Z가
해소되면서 이 표에서 `Ref`만 유일하게 비어 있게 됐음** — Attribute는
"이름 claim"이라는 국소 레지스트리로 갔으니, `Ref`도 같은 모양(`Ref →
현재 자리` 단방향 `Relate` + 즉시 error)이 자연스러운 선택지. 여전히
**M0를 막지는 않음**.
**선택지**: (a) `Slot`/`PreRef`와 같이 즉시 error(일관성 높음, `Relate`
하나로 Ref→현재 자리 추적), (b) UB로 두고 문서화만(현상 유지 —
단 증상이 "조용한 값 소실"이라 다른 UB보다 나쁨), (c) 마지막 쓰기 승리를
정식 동작으로 인정(비권장, `Ref`의 "확정된 값 박스" 의미와 충돌).
## 1. 용어 정리 — 아직 안 정해진 것만 (사용자 요청, 진행 중)
사용자 원 메모: "quad는 register라던가 좀 부정확하거나 느낌이 바로 와닿지
@ -109,11 +59,15 @@
점이 지적됨. `canExecute`는 타입이 아니라 liveness(생존 여부)를 묻는
질문이라 `is`보다 `can` 계열 접두를 유지하는 쪽이 낫다는 방향으로 사용자가
기욺 — 여전히 미확정, 다음에 `can`으로 시작하는 구체 대안(예: `canRun`)을
같이 검토할 것. **[2026-08-14 다섯 번째 세션] 열려 있는 건 이름뿐**
— 시그니처는 `canExecute(value): boolean` 1-인자로 확정됐고(옛
`(inst, value)` 2-인자는 폐기), 폐기된 `canBound`의 몫까지 이 하나가
겸함(`base/lifecycle-pattern.md`, `archive/canexecute-inst-arg-reversed.md`)
— 이름을 바꾸면 그 두 역할을 다 담아야 함에 유의.
같이 검토할 것. **[2026-08-14 다섯 번째 세션] 시그니처는
`canExecute(value): boolean` 1-인자로 확정**(옛 `(inst, value)` 2-인자는
폐기, `base/lifecycle-pattern.md`, `archive/canexecute-inst-arg-reversed.md`).
**[2026-08-14 열한 번째 세션 정정]** 당시엔 `canBound`가 폐기돼
`canExecute` 하나가 두 역할을 겸했으나, 이후 `canBound`가 별도
진입점으로 재도입되며 다시 갈라짐(같은 문서의 "`canBound` vs
`canExecute`" 절) — 이 이름 정리 항목은 이제 `canExecute`(emit 게이팅
전용)에만 적용되고, `canBound`(이중 바인딩 가드 전용)는 별개 이름으로
유지됨. 둘 다 판정 로직은 비공개 헬퍼 하나를 공유.
- **클로저 인자 이름 `hintValue`(3순위, 사소함, 2026-08-13 열네 번째
세션 신설)**: 하강 diff 재디스패치에서 이 인자는 더 이상 "힌트"가
아니라 **`nil`이거나 같은 핸들러가 곧 처리할 새 값**임이 계약으로
@ -170,11 +124,12 @@
엘비스 연산자류) 후보 추가 — Haskell 비교 리서치 중 나옴, 카탈로그 확정
규칙에 그대로 맞아 포함 근거는 있음. 상세는 `research/operator-sugar-plan.md`.
구현 자체는 맨 마지막 우선순위(순수 슈가, 없어도 무방) — 여전함.
- **[신설, 2026-08-14] Debounce/Throttle — 남은 열린 질문 4개** —
- **[신설, 2026-08-14] Debounce/Throttle — 남은 열린 질문(개수는
`research/debounce-throttle-plan.md` 12절이 소스, 여기서 반복 안 함)** —
사용자 요청("`Blocker`와 유사하게 만들어야 한다")으로
`research/debounce-throttle-plan.md` 신설. 네 번의 리뷰 라운드로
**패키지 경계·주입 op 시그니처·`Timeout` 타입·동작 정식화는 전부
확정**됐고, 사용자 취향/방향이 갈리는 것만 남음:
확정**됐고, 사용자 취향/방향이 갈리는 것만 남음 — 주요 4개:
- **의미론** — 창이 열려 있는 동안 `:Get()`이 최신값을 주는가
(`Blocker`와 동일) vs 지연된 값을 주는가(권장, VueUse/RxJS와 일치).
- **제어 핸들**`state:Apply(Debounce{...})`로 끝낼지, `Blocker`
@ -185,7 +140,11 @@
업계 표준 이름을 유지하고 문서로 경고할지, 다른 이름을 쓸지.
- **`Time = 0` 허용 여부** — "이번 스텝 합치기"로 유용하지만 스케줄러
타이밍에 의존하는 준-`Blocker`가 됨.
우선순위 낮음 — M0를 막지 않고 코어 계약도 안 건드림. 다만 **M3에서
이 외에 사소한 것 둘도 12절에 열려있음: 공개 생성자 2개 개정안이
사용자 확인만 남은 상태, `base/blocker-plan.md`가 인용하는 "`Get()`은
라이브 레퍼런스" 문구에 한 줄 명확화가 필요(확정 문서 수정이라 승인
대기, 아직 안 건드림). 우선순위 낮음 — M0를 막지 않고 코어 계약도 안
건드림. 다만 **M3에서
`Blocker`를 구현할 때 게이티드 노드를 공용으로 빼두는 것**만은 그
시점에 해야 함(따로 하면 같은 설계를 두 번 함).
- **중첩 State 평탄화 `State<State<T>>``State<T>`(2026-08-13 여섯 번째

View file

@ -67,19 +67,18 @@ init.luau`, ~1000줄) + `charm-sync`(클라/서버 상태 복제 diff 레이어)
둔 이유 — quad의 배열/해시 파트 `None` 센티널 정당화(`base/
bind-system-plan.md:180-266`)와 동기 없이 같은 결론에 수렴한 사례.
새 아이디어는 아니고 인용 근거로만 가치 있음.
- **quad가 미결로 남긴 "previous 값 비교" 문제에 대한 두 가지 답.**
(1) `signal(initialValue, equals?)`(`init.luau:432`, `Equals<T>` 타입은
- **charm의 `previous` 유사 메커니즘 두 가지 — quad가 이미 확정한
`:Compute(fn)``previous` 인자(`base/source-state-plan.md` "previous"
절, `fn(self, previous?, ...deps)`)에 대한 독립 실동작 증거.** (1)
`signal(initialValue, equals?)`(`init.luau:432`, `Equals<T>` 타입은
23행)는 생성 시점에 `initialValue`를 항상 요구해서 "비교할 이전 값이
아직 없다"는 애매한 첫 상태 자체를 구조적으로 없앰 —
`research/additional-primitives-plan.md`가 남겨둔 "비교할 이전 값이
확정 안 된 문제"에 대한 한 가지 해법 형태. (2) `computed(getter)`
아직 없다"는 애매한 첫 상태 자체를 구조적으로 없앰. (2) `computed(getter)`
getter에 **이전 계산 결과**를 인자로 넘겨줌(`init.luau:538`,
`(previousValue: T?) -> T`, README 276-287행, `computed.test.
luau:84-104`가 홀수 업데이트를 스킵하는 걸로 실제 검증) — quad의
`base/source-state-plan.md`가 이미 띄워둔 "`:Compute(fn)`에 선택적
두 번째 `previous` 인자" 안과 거의 동일한 모양. 새로 수입할 아이디어가
아니라 **이미 검토 중인 안이 실제로 동작한다는 정황 증거**로 인용
가치 있음.
luau:84-104`가 홀수 업데이트를 스킵하는 걸로 실제 검증) — quad가
`base/source-state-plan.md`에서 이미 확정한 `previous` 안과 거의 동일한
모양. 새로 수입할 아이디어가 아니라 **이미 확정된 안이 실제로 동작한다는
정황 증거**로 인용 가치 있음(더 이상 열린 질문 아님).
- **charm-sync의 diff/patch 메커니즘 — quad가 아직 전혀 안 다뤄본 영역이라
가장 새로운 참고자료.** `patch.luau:59-89`(`diff`)가 재귀적 구조적
diff로 중첩 patch 테이블을 만들고, `apply`/`applyMutable`
@ -116,8 +115,8 @@ init.luau`, ~1000줄) + `charm-sync`(클라/서버 상태 복제 diff 레이어)
기각한 패턴을 그대로 구현하고 있음 — 사용자가 애초에 예상한 "짧은
라이브러리라 새로운 게 없을 것"이 이 레이어에는 대체로 맞음. 진짜 참고
가치는 코어 밖에 있음: charm-sync의 diff/patch(현재 quad 스코프 밖이지만
새 영역), 그리고 quad가 미결로 열어둔 Blocker의 "previous 값 비교" 문제에
대한 두 가지 실동작 사례(`signal`의 필수 initialValue, `computed`
새 영역), 그리고 quad가 이미 확정한 `:Compute``previous` 인자가 실제로
동작한다는 두 가지 실동작 사례(`signal`의 필수 initialValue, `computed`
previous-in-getter). 지금 당장 base 문서를 고칠 만한 발견은 없음 — 순수
참고자료로 등록.

View file

@ -7,9 +7,12 @@ batch-rejected.md`, Context(+레이어드 Store 대안) → `archive/
context-rejected.md`. **[2026-08-09 세 번째 세션]** 마지막으로 남아있던
키 기반 동적 컬렉션 재조정도 `Slot:List(...)` 메소드로 완전히 확정되어
`base/slot-plan.md`로 승격됨(아래 절은 요약+포인터만 남기고 상세는 그쪽
참고) — **이 문서에 새로 열려있는 설계 질문은 더 이상 없음**, 아래 표/
"빈 자리 아닌 것"/"문서화 백로그"/"참고 소스" 절은 배경 리서치 기록으로만
유지.
참고) — **[2026-08-09 세 번째 세션 기준] 이 문서에 새로 열려있는 설계
질문은 없었음**, 아래 표/"빈 자리 아닌 것"/"문서화 백로그"/"참고 소스"
절은 배경 리서치 기록으로만 유지. **단 이후 세션에 열린 질문 2개가
새로 추가됨** — "Attribute 그룹 명시적 unset 유틸"(2026-08-12),
"중첩 State 평탄화 `State<State<T>>`"(2026-08-13 여섯 번째 세션, 이후
`question.md` 백로그로 근거 축소) — 아래 해당 절 참고.
## 배경

View file

@ -188,11 +188,12 @@ Modifier/Ref를 아예 안 넘기는 케이스를 반드시 포함시킬 것.**
### 1-6. `canExecute`/`Connected`의 실제 구현 방식이 미확정인 채로 코어 전역에 이미 재사용 확정됨
**[해소됨, 2026-08-08 세션. 시그니처는 2026-08-14 다섯 번째 세션에 정정]**
`bindLifetime(inst,value)`/`unbindLifetime(value)`/`canExecute(value)`
탑레벨 함수로 확정, `Relate` 프리미티브(`base/relate-plan.md`) 위에 gcconn/
**[해소됨, 2026-08-08 세션. 시그니처는 2026-08-14 다섯 번째 세션에 정정,
`canBound`는 열한 번째 세션에 별도 진입점으로 재도입]**
`bindLifetime(inst,value)`/`unbindLifetime(value)`/`canBound(value)`/
`canExecute(value)` 탑레벨 함수로 확정, `Relate` 프리미티브(`base/relate-plan.md`) 위에 gcconn/
gchold를 얹는 구체 구현까지 나옴 — `base/lifecycle-pattern.md`
"`bindLifetime`/`canExecute`/`unbindLifetime` — 확정" 절이 최신
"`bindLifetime`/`canBound`/`canExecute`/`unbindLifetime` — 확정" 절이 최신
(뒤의 둘이 `inst`를 안 받게 된 경위는
`archive/canexecute-inst-arg-reversed.md`). 아래는 이 결정이 나오기
전까지의 문제 서술(정확했던 문제 인식이라 그대로 둠, 남은 실측 항목은

View file

@ -0,0 +1,262 @@
# 2026-08-14 열한 번째 세션 — 코퍼스 전체 감사(서브에이전트 6개 병렬) + `canBound`/`canExecute` 재분리, `question.md` 0-W 해소
## 코퍼스 감사
사용자 요청으로 `.claude/` 전체를 감사 — 먼저 `python3 .claude/tools/doc-check.py`
기계 점검(ERROR 0 확인)한 뒤, `base/`(3분할)·`research/`+`reference/`·
`luau-test/`+`audit/`+`archive/`·루트 인덱스 파일(CLAUDE.md/ROADMAP.md/
HUMAN_TODO.md/question.md/`.claude/README.md`) 6개 영역을 서브에이전트로
병렬 감사, 보고된 항목을 직접 재확인 후 실제 사실 오류만 반영. 총 15개
파일 수정:
- `modifier-plan.md` 2곳 — `Brand`/`isState` 정의 인용이 9차 세션 문서
분할 후 옛 경로(`bind-system-plan.md`)를 계속 가리킴 → `brand-plan.md`
정정
- `tag-plan.md``TagHandler.priority` 의사코드가 `HANDLER_PRIORITY_FALLBACK`
이어야 하는데 `<일반>`으로 남아 같은 문서의 "패키지 배치" 절과 자기모순
- `typing-limits.md` — 스파이크 경로가 `review-required/08`로 stale(13차
세션에 `done/08`로 이동)
- `store-plan.md``store.key` 타입함수 접근을 "실측 확인"처럼 과장
서술해 `typing-limits.md`(스파이크 `16`은 여전히 `rewrite-required/`,
미검증)와 모순 → caveat 추가
- `additional-primitives-plan.md` — "새로 열린 질문 없음" 배너가 이후
세션에 추가된 열린 질문 2개(Attribute unset 유틸, `State<State<T>>`
평탄화)와 자기모순
- `comparison-charm.md`+`.claude/README.md` — "quad가 미결로 남긴 previous
값 비교 문제"라며 존재하지 않는 문서(`additional-primitives-plan.md`)를
잘못 인용 — 실제로는 `source-state-plan.md`에 이미 확정된 `:Compute`
`previous` 인자
- `luau-test/STATUS.md`/`README.md` — `canExecute` 재정정 세션 번호가
"세 번째"(STATUS.md 헤더 1곳, README.md 2곳)와 "다섯 번째"(같은 파일
본문, archive, CLAUDE.md)로 갈라짐 — archive 3곳 교차확인 결과
"다섯 번째"가 맞음, `05`(8차 세션 이동)도 README 테이블에 누락돼 있어
보강
- `question.md`/`HUMAN_TODO.md` — 0-W가 "M4 구현 세부만 막음"이라
서술했는데 실제 `Ref` 구현은 M8 — 정정, `ROADMAP.md` M8에 포인터 추가
- `question.md` — Debounce/Throttle "남은 열린 질문 4개"가 실제
`debounce-throttle-plan.md` 12절엔 6개(항목 3/7 누락) → 개수 서술
제거하고 소스 문서로 위임
- `CLAUDE.md` 자기 자신 2곳 — `luau-test/rewrite-required` 나열이
`05`(8차 세션 합류)/`04`/`19`(14차 세션 합류)를 빠뜨려 stale, "지금
할 일" 절엔 "더 이상 대기 항목 아님"이 바로 위 문단과 자기모순 →
나열 자체를 걷어내고 `STATUS.md`로 위임
`doc-check.py` ERROR 0 유지 확인. 발견 없음으로 보고된 영역:
`architecture`/`attribute`/`bind-system`/`blocker`/`brand`/
`component-composition`/`dispatch-core`/`effect`/`event`/`fallback`/
`lifecycle-hooks`/`lifecycle-pattern`(*아래 별도 발견 있었음, 감사 라운드
자체는 통과 보고했으나 이후 논의 중 직접 발견*)/`module-lifecycle`/
`onchange`/`purity-and-effects`/`ref`/`relate`/`source-state`/`tween`/
`ui-shorthand-plan`, `debounce-throttle`/`debug-tooling`/
`documentation-*`/`framework-comparison`/`operator-sugar`/
`pre-implementation-audit`/`v1-compat-plan`, `comparison-fusion-vide`,
`quad-v1-architecture`, `archive/` 23개 전체, `audit/` 나머지.
## `canBound`/`canExecute` 재분리, `question.md` 0-W 해소
감사 완료 후 사용자가 `question.md` 0-W(같은 `Ref`가 두 자리에 동시에
놓이는 걸 막을지)에 대해 이미 마음속으로 결정해뒀던 것을 풀어놓음 — 선택지
(a)(즉시 error) 확정, 메커니즘은 `RefLeafHandler``bindLifetime`/
`unbindLifetime`을 재사용(새 전용 `Relate` 불필요 — `bindLifetime`이 이미
내장한 이중 바인딩 가드를 그대로 타면 됨). 그런데 이 가드가 지금
`canExecute`라는 이름으로 되어 있는 게 이상하다는 지적 — `canExecute`
"emit 처리로 State 전파 루프가 Effect/Observer를 발화시켜도 되는가"를
묻는 함수인데, `Ref`는 emit 전파에 참여조차 안 하니 그 이름을 쓰는 게
개념적으로 안 맞음. "bound 문맥"(이미 묶여 있는가 — `bindLifetime`/
`Observer:Subscribe()`의 이중 등록 가드가 묻는 것)과 "execute 문맥"(지금
발화해도 되는가 — State 전파 루프만 묻는 것)은 오늘 판정값이 같아도
서로 다른 질문이라는 게 사용자 논거.
**해법(사용자 제안) — 이름은 둘, 판정 로직은 하나.** 2026-08-14 다섯
번째 세션이 "canBound 폐기, canExecute로 통합"했던 걸 부분적으로 되짚어
`canBound`를 별도 진입점으로 재도입하되, 실제 gcconn/`.Subscribed` 체크는
비공개 헬퍼 `isBoundAlive(value)` 하나에만 두고 `canBound`/`canExecute`
둘 다 그 헬퍼를 부르는 얇은 래퍼로 — 코드 중복 없이 호출부 의미만 분리.
**다섯 번째 세션이 고친 것(2-인자 `canExecute(inst,value)`가 오염이었다는
것, `unbindLifetime`/`canExecute`가 `inst`를 안 받아야 한다는 것)은 전부
그대로 유효** — 되짚은 건 "합칠지 나눌지"라는 좁은 하위 결정뿐.
반영: `base/lifecycle-pattern.md`(정본, `isBoundAlive`/`canBound`/
`canExecute` 코드 전면 재작성 + "canBound vs canExecute" 절 신설),
`base/ref-plan.md`("이중 배치 방지" 절 신설, `RefLeafHandler` 의사코드에
`bindLifetime`/`unbindLifetime` 호출 추가), `archive/
canexecute-inst-arg-reversed.md`(재분리 경위 addendum), `question.md`
0-W 항목을 `archive/question-resolved.md`로 이전, `ROADMAP.md`
M2/M3/M8 체크리스트, `base/source-state-plan.md`의 "이중 바인딩 금지"
절(게이트를 `canBound`로 교체), `base/effect-plan.md`(UB 절 갱신 +
"세 번째 세션" 오표기를 "다섯 번째"로 같이 정정 — lifecycle-pattern.md에서
도 같은 오표기 발견해 정정), `base/architecture.md`(소스 트리 주석),
`base/dispatch-core-plan.md`/`research/pre-implementation-audit.md`
(절 제목 인용 갱신), `luau-test/README.md`/`audit/
gcconn-trick-verification.md`(스파이크 `10` 재작성 시 반영할 새 게이트
이름 갱신), CLAUDE.md 자신("지금 할 일" 0번 — `question.md`의 "결정
대기" 절이 이제 완전히 빔).
`doc-check.py` ERROR 0 유지. 새로 연 설계 질문 없음 — `Slot`
`claimOwner`/`Attribute`의 이름 claim처럼 `Ref`도 기존 프리미티브
(`bindLifetime`)를 재사용하는 것으로 끝남.
## 후속 — `Frame1{Ref=r}` 표기 정정, `PreRef`/`PostRef`/`Observer`/`Effect`
동적 경로 가드를 `HANDLER_PRIORITY_FALLBACK`으로 통일
사용자가 0-W 손 트레이싱의 `Frame1 { Ref = r }` 표기를 질문 — `Ref`
항상 children **배열** 리터럴로만 놓이므로 `k`가 문자열 `"Ref"`일 리
없고(실제로는 배열 인덱스, 숫자), 순수 설명 편의 표기였음을 확인.
`base/ref-plan.md`의 새 절과 `archive/question-resolved.md`에 보존된
원본 트레이싱에 정정 각주 추가(원본 자체는 역사 보존 목적으로 안 지움).
이어서 사용자가 `PreRef`/`PostRef`/`Observer`/`Effect`가 non-number
키(해시 파트 등 동적 경로)로 들어오면 어떻게 할지 질문 — 처음엔 "`k`
무관하게 매치해서 `process`에서 에러"(Attribute처럼 process 도중
에러내는 기존 패턴)를 제안했다가, 곧바로 스스로 "가장 아래(FALLBACK)에
까는 게 맞다"고 정정. 결론: `PreRef`의 기존 "동적 경로 가드" Handler가
이미 `k` 무관 매치+에러였지만 우선순위가 명시돼 있지 않았던 걸
`HANDLER_PRIORITY_FALLBACK`으로 확정 — 하드 블록이 아니라 `Tag`/
`Attribute`와 같은 "base가 소유하되 평범한 우선순위의 다른 Handler가
있으면 그쪽이 이기는" 자리로 만들어, 지금은 항상 에러가 나지만 나중에
named 자리 바인드 같은 실제 기능이 확정되면 base 가드를 안 건드리고
그 기능의 Handler만 등록하면 자동으로 우선하게 됨. 같은 패턴을 `PostRef`
(기존 거울상 가드에 우선순위 추가)와 `Observer`/`Effect`(기존엔 전용
가드가 없어 범용 "매치 실패=에러" 경로로 우연히 같은 결과를 내고
있었음 — 이번에 전용 `HANDLER_PRIORITY_FALLBACK` 가드를 신설해 넷 다
명시적으로 통일, 동작 자체는 안 바뀌고 에러 메시지만 명확해짐)에도
적용. `base/ref-plan.md`/`base/source-state-plan.md`/`base/effect-plan.md`/
`ROADMAP.md` M8/M3 반영, `doc-check.py` ERROR 0 유지.
이어서 사용자가 Tag/Attribute의 미주입 백엔드 실패 모드(base 기본
스텁이 명확한 에러를 낸다는 것)를 확인한 뒤, "미지원"과 "미등록"을
구분 안 하는 게 맞는지 질문 — 확정 원칙(`pre-implementation-audit.md`
1-4, 스텁 입장에선 원천적으로 구별 불가) 그대로 재확인. 사용자가
"정말로 미지원인 백엔드는 FALLBACK보다 높은 우선순위로 자기 Handler를
등록해 명시적으로 에러내면 된다"는 관례를 문서에 말로만 적어두자고
제안(강제 메커니즘 아님, 대부분 백엔드가 결국 지원할 거라 실익은
크지 않지만 opt-in 옵션으로) — `base/dispatch-core-plan.md`의 "base가
소유하는 핸들러와 주입되는 엔진 op" 절에 관례 bullet 추가.
**곧바로 더 단순한 안으로 정정**: 사용자가 "그런 환경은 addTag든 뭐든
nop일 수도 있으니, [모든 백엔드가] 이 op들을 다 등록하도록 해야 하고,
대신 [미지원이면] 에러로 '구현 안 되어있다'를 내도록 [팩토리가 직접
작성]하면 된다"고 제시 — 방금 추가한 "Handler 우선순위로 덮어씌우기"
관례를 철회하고, 대신 **op 등록 자체를 필수로** 바꿈: 백엔드 팩토리는
`addTag`/`removeTag`/`setAttribute`를 항상 명시적으로 채워야 하고(빈
자리로 두고 base의 미주입 스텁에 기대지 않음), 지원 안 하는 op은
`nop`(정당한 구현 선택)이든 `function() error("구현 안 됨") end`(명시적
에러)든 팩토리가 직접 등록. Dispatch Handler나 우선순위 조정이 전혀
불필요해서 이전 안보다 훨씬 가벼움 — 부수 효과로 "provider 미주입"과
"미지원"도 자연히 갈라짐: 제대로 된 팩토리는 항상 세 op를 다 채우므로,
base 스텁이 실제로 매치되는 건 "어떤 팩토리도 안 돌았다"는 훨씬 좁은
경우로 좁혀짐. `base/dispatch-core-plan.md`/`base/module-lifecycle-plan.md`
반영, `doc-check.py` ERROR 0 유지.
**다시 한 번 정정 — 사용자가 이번엔 op 필수화만으론 부족하다고 지적.**
`addTag` 에러는 `TagHandler.process` 본문 안, 즉 `tagNameMap` 같은 부기가
이미 일부 mutate된 *뒤*에야 남 — `Tag()`가 "바인드는 됐는데 엔진 반영만
실패"인 반쪽짜리 상태로 남을 수 있어 원자적 실패가 아님. 사용자 결론:
"그래도 여전히 Tag()는 바운드 가능함... 핸들러도 1만큼 높은 우선순위
채워주는 게 가장 안전하고 사실상 무료" — op 필수화(위)는 유지하되,
**추가로** 미지원 백엔드는 `HANDLER_PRIORITY_FALLBACK + 1` 우선순위의
순수 가로채기 Handler(`isHandlable = isTag(v), process = error(...)`)도
등록해 `TagHandler.process` 자체가 아예 안 불리게(부기 mutation 0회) 하는
걸 권장으로 추가 — 매치된 Handler 하나만 실행되는 기존 Dispatch 규칙을
그대로 이용하는 것이라 새 메커니즘 없음. `base/dispatch-core-plan.md`/
`base/module-lifecycle-plan.md` 재반영, `doc-check.py` ERROR 0 유지.
**세 번째 정정 — 사용자가 "철회 아님, 전제 자체가 틀렸다"고 바로잡음.**
어시스턴트가 "`TagHandler`/`AttributeKeyHandler`는 quad-base가 자기
모듈 로드 시점에 스스로 등록한다"고 잘못 전제하고 "+1 Handler" 안을
철회했었는데, 사용자가 정정: **Tag/Attribute는 모든 백엔드의 필수
구현이 아니고, quad-base는 이 Handler들을 완성된 값으로 export만 할
뿐 스스로 Dispatch에 등록하지 않는다.** 백엔드 팩토리가 둘 중 하나를
선택: **(A) 지원함** — quad-base Handler를 그대로 등록 + `addTag`/
`removeTag`/`setAttribute` 실제 구현 주입, **(B) 지원 안 함** — 그
Handler는 등록하지 않고 `HANDLER_PRIORITY_FALLBACK + 1` 우선순위의
얇은 "미지원" 가로채기 Handler만 등록(op 주입 자체가 불필요 — 호출할
주체가 그 백엔드의 레지스트리에 없음). 이 모델이면 "op 에러가 부기
mutation 뒤에 지연 발생"하는 문제 자체가 안 생김 — (B)를 고른 백엔드에서
부기를 mutate하는 `TagHandler.process`가 아예 안 불리기 때문. 앞서
검토했던 "op 필수화"와 "process 재정렬" 두 안은 전부 이 등록-선택
모델로 대체되어 불필요해짐. `base/dispatch-core-plan.md`(정본, 전면
재작성)/`base/module-lifecycle-plan.md`/`base/tag-plan.md`/
`base/attribute-plan.md`/`base/architecture.md` 반영, `doc-check.py`
ERROR 0 유지.
**네 번째, 최종 정정 — "등록-선택 모델" 자체가 틀렸음, 사용자가 바로잡음.**
사용자: "HANDLER_PRIORITY_FALLBACK까지 등록 안한다 했는데, 개소리 ㄴㄴ
HANDLER_PRIORITY_FALLBACK는 아무 등록 없는데 처리하려 할 때 방어까지
포함하는 애임. 기본 등록은 필요함." **최종 확정 모델**: `TagHandler`/
`AttributeKeyHandler`/`AttributeGroupHandler`는 quad-base가 모듈 로드
시점에 `HANDLER_PRIORITY_FALLBACK`으로 **스스로 등록**(모든 백엔드가
자동으로 부기를 얻음, FALLBACK 밴드의 존재 이유 자체가 이 "기본
안전 동작 제공"임). `addTag`/`removeTag`/`setAttribute`만 백엔드
팩토리가 채우는 타입 계약이고, 안 채운 슬롯의 base 기본값은 "그럴듯한
기본 동작을 추측"하지 않고 **명시적으로 에러내는 스텁**(조용한 no-op은
provider 초기화를 잊은 실수를 가려버려서 기각). 더 명확한 메시지나
진짜 원자적 실패(부기 mutation 0회)를 원하는 백엔드만 **opt-in으로**
`HANDLER_PRIORITY_FALLBACK + 1`짜리 가로채기 Handler를 추가로 등록—
이게 필수 요구사항이 아니라 선택적 업그레이드라는 게 핵심(base 기본
스텁 하나로도 `AttributeGroupHandler`의 "부분 실패 경로" 원칙만으로
충분히 안전). `base/dispatch-core-plan.md`(정본, 다시 전면 재작성)/
`base/module-lifecycle-plan.md`/`base/tag-plan.md`/
`base/attribute-plan.md`/`base/architecture.md` 재반영, `doc-check.py`
ERROR 0 유지.
**교훈**: 이번 라운드는 네 번 연속 정정이 있었음(op 위치 우선순위 →
op 필수화 → 등록-선택 모델(틀림) → base가 스스로 등록(최종)) — 매번
"왜 이게 맞는지"를 서둘러 확정하기 전에 실제 아키텍처 전제(누가
Dispatch.addHandler를 부르는가)부터 정확히 확인했어야 했음. 이 전제
하나가 중간 두 라운드의 결론을 전부 무효화시켰음 — 확신 없는 아키텍처
주장은 재확인 없이 단정하지 말 것.
## 후속 — 세션 전체 자기 감사(사용자 요청 "다른곳에도 실수한거 있는지 찾아봐")
`git diff`로 이 세션에서 건드린 25개 파일 전체를 처음부터 다시 정독 —
`canBound`/`canExecute` 재분리, `Ref` 0-W 해소, PreRef/PostRef/Observer/
Effect 동적 경로 가드, Tag/Attribute 등록 모델, 그리고 최초 코퍼스
감사 15건 전부 논리적 일관성 재확인. 실제 문제 3건 발견·수정:
1. **`HUMAN_TODO.md`가 0-W 해소 이후에도 "남은 건 0-W" 그대로 방치돼
있었음** — 세션 도중 `question.md`/CLAUDE.md는 0-W 해소를 반영했는데
`HUMAN_TODO.md`만 그 전 라운드(M4→M8 정정)에서 멈춰 있던 stale 갱신
누락. "결정 대기 절 자체가 비어있음"으로 정정.
2. `base/ref-plan.md`의 "이중 배치 방지" 절에 편집 중 생긴 어색한 줄바꿈
(문장 중간에 orphan line) — 순수 포맷 정리.
3. **Tag/Attribute 등록 모델을 재정리하며 `tag-plan.md`/`attribute-plan.md`의
"백엔드가 통째로 다르게 처리하고 싶으면 override 가능" 문장을 실수로
빠뜨림** — "미지원 신호" 용도(opt-in `FALLBACK+1` 가로채기)만 남고
"알고리즘 전체 교체" 용도(그냥 더 높은 우선순위로 자기 Handler 등록)가
누락돼 있었음. 둘 다 유효한 별개 용도라 양쪽 파일에 복원.
나머지(canBound/canExecute 코드 블록의 순환·호출부 일관성, Ref
bindLifetime/unbindLifetime 배치, FALLBACK 우선순위 값들, 세션 로그·
CLAUDE.md 서술과 실제 base/ 반영 내용의 일치)는 전부 문제 없음 확인.
`doc-check.py` ERROR 0 유지.
## 후속 — `/code-review high`가 잡은 3건 추가 발견·수정
사용자가 백그라운드로 `/code-review high`를 돌림 — 위 자기 감사에서
놓친 진짜 문제 3건을 잡아냄(전부 "canBound 폐기"라는 5차 세션 당시의
서술이 11차 세션 재도입 이후에도 안 고쳐진 곳들, 자기 감사가
`git diff`로 이 세션이 **건드린** 파일만 훑어서 **안 건드린** 파일 속
5차 세션 원본 서술은 놓쳤던 사각지대):
1. `audit/gcconn-trick-verification.md`의 상단 배너(9-14행, 이 세션이
그 아래 섹션만 고치고 이 배너는 안 건드렸음)가 "canBound는 폐기됐고
게이트는 canExecute 하나"라고 여전히 서술 — 바로 몇 줄 아래(이번에
고친 섹션)와 정면 모순. 재정정.
2. `.claude/README.md``lifecycle-pattern.md`/`question-resolved.md`/
`canexecute-inst-arg-reversed.md`/`audit/` 인덱스 행 4곳 — 전부 이
세션 동안 한 번도 안 열어봤던 파일이라 5차 세션 서술("canBound
폐기")이 그대로 남아있었음. 전부 정정.
3. `luau-test/STATUS.md`의 스파이크 `10` 재작성 가이드 — "이중 바인딩
게이트는 canBound가 아니라 canExecute로 쓸 것"이라고 옛 모델을
**재작성 지침으로 명시**하고 있었음(가장 심각 — 이대로 따라 재작성하면
또 틀린 스파이크가 나옴). 재정정.
4. `question.md`의 "용어 정리" 절(canExecute 이름 논의)도 "canBound의
몫까지 canExecute가 겸함"이라는 5차 세션 전제 위에 서 있어서 같이 정정.
**교훈**: `git diff`만으로 하는 자기 감사는 "이 세션이 건드린 파일"의
내부 일관성만 보고, "이 세션이 안 건드렸지만 이 세션의 결정 때문에
stale해진 파일"은 놓침 — 이번처럼 이름 하나(`canBound`)가 재도입되면
그 이름을 인용하는 **모든** 파일을 grep으로 전수 스캔해야 하는데, 처음
자기 감사 땐 "내가 고친 파일들"만 재확인하고 "canBound"라는 문자열
자체로 전체 코퍼스를 훑지 않았음. `doc-check.py` ERROR 0 유지.

164
CLAUDE.md
View file

@ -55,11 +55,13 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
세기로 함).
- `.claude/luau-test/`**[2026-08-09 신설]** "추론만으로 확정하고 실제
Luau로 부딪혀본 적 없는 것"을 미리 검증하는 독립 실행 스파이크 20개
(`luau <파일>` / `luau-analyze <파일>`). **상태의 소스는 `STATUS.md`**
(pass / 사람 결정 필요 / 스파이크 깨짐 / 미실행), 각 파일이 뭘 왜
검증하는지는 `README.md`. 2026-08-13에 첫 실측 완료 — 런타임 12개 전원
통과, 남은 건 Studio 전용 `10`과 코드가 깨진 `13`/`15`/`16`
(`review-required/`는 13차 세션에 비워짐).
(`luau <파일>` / `luau-analyze <파일>`). **상태의 소스는 항상 `STATUS.md`**
(pass / 사람 결정 필요 / 스파이크 깨짐 / 미실행, 폴더 구조 자체가 상태),
각 파일이 뭘 왜 검증하는지는 `README.md`. 2026-08-13에 첫 실측 완료(당시
런타임 12개 전원 통과) — 이후 여러 세션에 걸쳐 재설계로 몇 건이 추가로
`rewrite-required/`에 합류했으니 **지금 몇 개가 어디 있는지는 여기서
나열 안 하고 `STATUS.md`로 미룸**(나열하다 stale해지는 패턴이 실제로
반복됐음, 아래 "지금 할 일" 0번 참고).
- `.claude/audit/`**[2026-08-13 신설]** 스파이크를 실제로 돌린 **실측
결과** 기록(계획 아님). 부분 확인도 있는 그대로 남김 —
`luau-test-first-run-2026-08-13.md`(첫 실측 라운드 전체, 구 0-Y의 1차
@ -159,13 +161,20 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
## 지금 할 일 (우선순위순)
0. **⭐ M0 착수를 막는 결정은 이제 없음 (2026-08-13 열네 번째 세션 기준).**
0. **⭐ M0 착수를 막는 결정은 이제 없음 (2026-08-14 열한 번째 세션 기준).**
`question.md`의 최우선 항목이 **전부 비었음**`0-Y`(`:Compute` lazy
핸들 계약)는 13차 세션에, **`0-Z`(Attribute 이름 소유권)와 `0-A`(재디스패치
하강 diff)는 14차 세션에 확정·`base/` 반영 완료**. 남은 `0-W`(같은 `Ref`
이중 배치)는 M0가 아니라 M4 구현 세부만 막음 — **[2026-08-14 열 번째
세션] `0-B`(`dispose` 시그니처/범위)도 해소**되어 `question.md`
"결정 대기"엔 이제 `0-W` 하나만 남음.
핸들 계약)는 13차 세션에, `0-Z`(Attribute 이름 소유권)와 `0-A`(재디스패치
하강 diff)는 14차 세션에, `0-B`(`dispose` 시그니처/범위)는 2026-08-14
열 번째 세션에 확정·`base/` 반영 완료. **`0-W`(같은 `Ref` 이중 배치,
M8 구현 세부만 막던 항목)도 2026-08-14 열한 번째 세션에 해소** —
선택지 (a) 채택(즉시 error), 메커니즘은 새 `Relate` 없이
`bindLifetime`/`unbindLifetime` 재사용(`base/ref-plan.md` "이중 배치
방지" 절). 부수 결정으로 **`canBound``canExecute`와 별도 진입점으로
재도입**됨(2026-08-14 다섯 번째 세션에 하나로 합쳤던 걸 부분적으로
되짚음 — "이미 묶여 있는가"(bound 문맥)와 "지금 발화해도 되는가"
(execute 문맥)는 판정 로직은 공유해도 호출부의 질문이 다르다는 사용자
지적, `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절).
`question.md`의 "결정 대기" 절 자체가 지금은 비어 있음.
**M0 착수 전 반드시 읽을 것 — 이 두 개는 "결정"이 아니라 "구현 규약"이라
여전히 유효**:
@ -195,9 +204,14 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
아래 그대로:
- **`.claude/luau-test/`(2026-08-09 신설, 2026-08-13 기준 20개) 스파이크
결과 — [2026-08-13 여섯 번째 세션에 첫 실측 완료, 대부분 닫힘].**
**상태의 소스는 `.claude/luau-test/STATUS.md`**(pass / 사람 결정 필요 /
스파이크 깨짐 / 미실행 4분류), 실행 결과 상세는
`.claude/audit/luau-test-first-run-2026-08-13.md`. 요지만:
**상태의 소스는 항상 `.claude/luau-test/STATUS.md`**(pass / 사람 결정
필요 / 스파이크 깨짐 / 미실행, 폴더 구조 자체가 상태) — 몇 개가 지금
어느 폴더에 있는지는 여기서 나열 안 함(04/05/10/13/15/16/19가 여러
세션에 걸쳐 재설계로 `rewrite-required/`에 들고나며 이 문단의 나열이
매번 stale해지는 패턴이 반복됐음, 최근엔 8차 세션의 "emit은 항상
전파" 정정으로 `05`도 합류). 실행 결과 상세는
`.claude/audit/luau-test-first-run-2026-08-13.md`. 첫 실측 요지만
(역사적 사실 — 이후 변동은 위처럼 `STATUS.md`가 소스):
- **런타임 12개 전원 통과**(01~07/11/17/18/19/20, crash 0 / FAIL 0) —
특히 `07`이 연쇄 GC를, `18`이 두-`Relate` 상호 순환 미해제를 실측
확정해 GC-native 아키텍처의 핵심 전제가 검증됨. `04`는 같은 세션
@ -206,23 +220,8 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
해소**(Luau 현 한계로 확정, `base/typing-limits.md`). 나머지 타입
스파이크는 판정 완료(`08`/`09` 통과, `12`는 실패지만 문서가 이미
fallback으로 예비해둔 결과라 설계 영향 없음, `14`는 부분).
- **남은 것은 셋뿐**: (1) `10`은 **Studio 전용이라 `luau` CLI로 못
돌림** — A 섹션 앞부분만 사용자 자작 스크립트로 부분 확인
(`audit/gcconn-trick-verification.md`), A-1/A-2/B/C 미확인.
(2) `13`/`15`/`16`은 **스파이크 코드 자체가 깨져 재작성 필요**
(설계 문제 아님 — 각각 더미 스텁 도달불가 / 음성 대조군이
`SyntaxError` / `types.*` 실제 API 불일치). (3) 그 외엔 그대로 M0
실제 코드 작성에 재사용.
- **[주의] 위 "남은 것은 셋뿐"은 여섯 번째 세션 기준** — 13차 세션에
`08``done/`으로 가며 `review-required/`가 비었고, **14차 세션에
`04``19`가 하강 diff 재설계로 전제가 바뀌어 `rewrite-required/`
갔음**(`19`는 B 섹션만 낡음, A/C는 유효).
**개수의 소스는 항상 `luau-test/STATUS.md`.** 지금 M0 착수를 막는
설계 결정은 없고, 0-Y/0-A가 남긴 규약(`base/typing-limits.md`/
`base/dispatch-core-plan.md`)은 착수 전 필독.
- 참고로 `04`(인덱스 기반 재설계 반영)와 `19`의 B/C 섹션(폐기된
`rawNew`+`owners`/3분기 `claimOwner` 검증하던 것)은 **둘 다 여섯
번째 세션에 재작성 완료**되어 통과 상태 — 더 이상 대기 항목 아님.
지금 M0 착수를 막는 설계 결정은 없고, 0-Y/0-A가 남긴 규약
(`base/typing-limits.md`/`base/dispatch-core-plan.md`)은 착수 전 필독.
2. **용어 정리 — 1차 제안 이후 대부분 확정, 소수만 남음.** 최신 소스는
`.claude/question.md` 1번(개수 반복 안 함, 항목 추가/해소될 때마다 여기가
stale해지는 패턴이 반복됐어서). **[2026-08-13 정정]** `State`
@ -1368,3 +1367,106 @@ Modifier 필드 금지 규칙과 혼동해 잘못 답했다가 사용자 지적
`base/dispatch-core-plan.md`/`base/architecture.md`/
`base/lifecycle-hooks-plan.md`/`ROADMAP.md`/`HUMAN_TODO.md`/`README.md`
전부 반영, `doc-check.py` ERROR 0 유지.
**2026-08-14 열한 번째 세션 — 코퍼스 전체 감사(서브에이전트 6개 병렬),
`canBound`/`canExecute` 재분리로 `question.md` 0-W 해소**
(`session/2026-08-14-11-corpus-audit-canbound-resplit.md`)
사용자 요청으로 `.claude/` 전체를 `doc-check.py` + 6개 병렬 서브에이전트로
감사 — stale 세션 번호(luau-test 파일들의 "canExecute 재정정"이 "세 번째"
vs "다섯 번째"로 갈라짐, archive 교차확인 결과 다섯 번째가 맞음), 잘못된
인용(`comparison-charm.md`가 이미 확정된 `:Compute``previous` 인자를
존재하지 않는 문서 인용으로 "미결"이라 잘못 서술), 자기모순 배너(`tag-plan.md`
의사코드와 "패키지 배치" 절 우선순위 불일치, `additional-primitives-plan.md`
"새 질문 없음" 배너가 이후 추가된 질문 2개와 모순) 등 15개 파일의 실제
사실 오류를 발견·수정. 감사 후 사용자가 `question.md` 0-W(`Ref` 이중
배치 방지)를 선택지 (a)로 확정 — 메커니즘은 `RefLeafHandler`가 새
`Relate` 없이 `bindLifetime`/`unbindLifetime`을 재사용(이미 내장된
이중 바인딩 가드를 그대로 탐). 이 과정에서 그 가드 이름이 `canExecute`
게 개념적으로 안 맞다는 지적 — "이미 묶여 있는가"(bound 문맥, `bindLifetime`/
`Observer:Subscribe()`가 묻는 것)와 "지금 발화해도 되는가"(execute 문맥,
State emit 전파 루프만 묻는 것)는 판정값은 같아도 다른 질문이라, 2026-08-14
다섯 번째 세션에 "canBound 폐기, canExecute로 통합"했던 걸 부분적으로
되짚어 `canBound`를 별도 진입점으로 재도입(판정 로직은 비공개 헬퍼
`isBoundAlive` 하나로 계속 공유 — 코드 중복 없이 호출부 의미만 분리,
다섯 번째 세션이 고친 시그니처/오염 정정 자체는 안 바뀜). `base/
lifecycle-pattern.md`(정본)/`base/ref-plan.md`/`archive/
canexecute-inst-arg-reversed.md`(addendum)/`question.md`→`archive/
question-resolved.md`/`ROADMAP.md`/`base/source-state-plan.md`/
`base/effect-plan.md`/`base/architecture.md`/`base/dispatch-core-plan.md`/
`research/pre-implementation-audit.md`/`luau-test/README.md`/`audit/
gcconn-trick-verification.md`/CLAUDE.md 자신까지 전부 반영,
`doc-check.py` ERROR 0 유지. 새로 연 설계 질문 없음.
**같은 세션 후속**: 손 트레이싱의 `Frame1 { Ref = r }` 표기가 실제
API와 다르다는 사용자 지적(`Ref`는 항상 배열 리터럴이라 `k`는 숫자,
`"Ref"`라는 문자열 키가 아님) — `ref-plan.md`/`archive/question-resolved.md`
정정. 이어서 `PreRef`/`PostRef`/`Observer`/`Effect`가 non-number 키로
들어오면 어떻게 할지 논의 — 사용자가 처음엔 "process에서 에러"를
제안했다가 스스로 "가장 아래(FALLBACK)에 까는 게 맞다"고 정정, 넷 다
`HANDLER_PRIORITY_FALLBACK`으로 등록하는 동적 경로 가드로 통일(하드
블록이 아니라 `Tag`/`Attribute`처럼 나중에 평범한 우선순위 Handler로
override 가능한 자리) — `ref-plan.md`/`source-state-plan.md`/
`effect-plan.md`/`ROADMAP.md` 반영. 마지막으로 Tag/Attribute의 미주입
백엔드 스텁 에러가 "미지원"과 "미등록"을 구분 안 하는 게 맞는지 확인
질문 — 확정 원칙(스텁 입장에선 원천적으로 구별 불가) 재확인. 처음엔
"FALLBACK보다 높은 우선순위 Handler로 덮어씌우기" 관례를 문서화했으나,
사용자가 곧바로 더 단순한 안으로 정정 — **op 등록 자체를 필수로
바꿈**: 백엔드 팩토리는 `addTag`/`removeTag`/`setAttribute`를 항상
명시적으로 채워야 하고(`nop`도 정당한 구현, 미지원이면
`function() error(...) end`), base의 미주입 스텁에 기대는 정상 경로는
없앰 — Dispatch Handler/우선순위 조정 불필요해서 훨씬 가볍고, 부수
효과로 "provider 미주입"(어떤 팩토리도 안 돌았다는 좁은 경우) vs
"미지원"(팩토리가 명시적으로 에러 등록)이 자연히 갈라짐.
`dispatch-core-plan.md`/`module-lifecycle-plan.md` 반영. **바로 다시
정정** — 사용자가 op 필수화만으론 부족함을 지적: `addTag` 에러는
`TagHandler.process` 본문 안, 부기(`tagNameMap` 등)가 이미 일부
mutate된 뒤에야 나서 원자적 실패가 아님(`Tag()`가 반쪽짜리 상태로
남을 수 있음). 결론: op 필수화는 유지하되, 미지원 백엔드는 **추가로**
`HANDLER_PRIORITY_FALLBACK + 1` 우선순위의 순수 가로채기 Handler도
등록해 `TagHandler.process`가 아예 안 불리게(부기 mutation 0회) 하는
걸 권장으로 추가 — "매치된 Handler 하나만 실행"이라는 기존 Dispatch
규칙을 그대로 이용, 새 메커니즘 없음. 같은 두 문서 재반영.
**세 번째 정정(틀림, 곧바로 재정정됨)** — 어시스턴트가 "TagHandler는
quad-base가 자기 모듈 로드 시점에 스스로 등록 안 하고, 등록 여부는
백엔드 팩토리의 (A)지원/(B)미지원 선택"이라는 모델을 제시했으나, 이는
어시스턴트 자신의 오독이었음(사용자가 실제로 한 말이 아님). **네 번째,
최종 정정** — 사용자가 명확히 바로잡음: "HANDLER_PRIORITY_FALLBACK까지
등록 안한다 했는데, 개소리 — FALLBACK은 아무 등록 없을 때 처리하려는
걸 방어하는 것까지 포함하는 애임, 기본 등록은 필요함." **최종 모델**:
`TagHandler`/`AttributeKeyHandler`/`AttributeGroupHandler`는 quad-base가
모듈 로드 시점에 `HANDLER_PRIORITY_FALLBACK`으로 **스스로 등록**(모든
백엔드가 자동으로 부기를 얻음 — FALLBACK 밴드의 존재 이유 자체가 이
"기본 안전 동작"을 공짜로 제공하는 것). `addTag`/`removeTag`/
`setAttribute`만 백엔드 팩토리가 채우는 타입 계약이고, 안 채운 슬롯의
base 기본값은 "그럴듯한 기본 동작을 추측"하지 않고 **명시적으로
에러내는 스텁**(조용한 no-op은 provider 초기화를 잊은 실수를 가려버려
기각). 더 명확한 메시지·진짜 원자적 실패를 원하는 백엔드만 **opt-in으로**
`HANDLER_PRIORITY_FALLBACK + 1`짜리 가로채기 Handler를 추가로 등록 —
필수가 아니라 선택적 업그레이드. `dispatch-core-plan.md`(정본, 다시
전면 재작성)/`module-lifecycle-plan.md`/`tag-plan.md`/`attribute-plan.md`/
`architecture.md` 재반영. **교훈**: 네 라운드 연속 정정이 있었던 이유는
"누가 실제로 Dispatch.addHandler를 부르는가"라는 아키텍처 전제를
확신 없이 매번 새로 단정하고 그 위에 patch를 쌓았기 때문 — 불확실한
전제는 재확인 없이 단정하지 말 것.
**같은 세션, 사용자 요청으로 전체 자기 감사**: 이 세션이 건드린 25개
파일을 `git diff`로 처음부터 재정독 — 실제 문제 3건 발견·수정
(`HUMAN_TODO.md`가 0-W 해소 이후에도 "남은 건 0-W"로 방치돼 있던 것,
`ref-plan.md`의 편집 중 생긴 어색한 줄바꿈, Tag/Attribute 등록 모델
재정리 중 `tag-plan.md`/`attribute-plan.md`에서 실수로 빠뜨린 "백엔드가
알고리즘 전체를 교체하고 싶으면 그냥 더 높은 우선순위로 등록" 문장
복원). 나머지는 문제 없음 확인, `doc-check.py` ERROR 0 유지.
**같은 세션, `/code-review high` 백그라운드 실행이 추가로 3건 발견**:
`git diff` 기반 자기 감사가 "이 세션이 건드린 파일"만 훑어서, "이
세션이 안 건드렸지만 `canBound` 재도입 때문에 stale해진 파일"을
놓쳤음 — `audit/gcconn-trick-verification.md` 상단 배너(고친 섹션
바로 위인데 안 건드림, "canBound 폐기" 서술이 몇 줄 아래 새 서술과
모순), `.claude/README.md` 인덱스 행 4곳(이 세션 동안 한 번도 안 열어본
파일이라 5차 세션 서술 그대로 잔존), `luau-test/STATUS.md`의 스파이크
`10` 재작성 가이드(가장 심각 — "이중 바인딩 게이트는 canExecute로 쓸
것"이라고 옛 모델을 재작성 지침으로 명시 중이었음), `question.md`
"용어 정리" 절. 전부 정정. **교훈**: 이름 하나가 재도입되면 그 이름을
문자열로 전체 코퍼스에 grep해야지, "내가 고친 파일들"만 재확인하는
건 안 충분함.

View file

@ -148,10 +148,10 @@ git branch -D worktree-debounce-throttle-plan
디자인 결정 중 Lua/Roblox 엔진에 대한 깊은 경험이 필요한 것들은 합리적 기본값으로
진행하면서 `.claude/question.md`에 모아두는 중. 깨어있을 때 훑어보고 기본값이
마음에 안 드는 것만 답해주면 됨 — **[2026-08-13 열네 번째 세션 기준]
M0 착수를 막는 항목은 하나도 없음**(남은 건 0-W `Ref` 이중 배치, M4 구현
세부만 막음 — 0-B `dispose` 시그니처/범위는 2026-08-14 열 번째 세션에
해소돼 `archive/question-resolved.md`로 이전됨).
마음에 안 드는 것만 답해주면 됨 — **[2026-08-14 열한 번째 세션 기준]
`question.md`의 "결정 대기" 절 자체가 비어 있음**(마지막 남았던 0-W
`Ref` 이중 배치도 이 세션에 해소 — `base/ref-plan.md` "이중 배치 방지"
절, `archive/question-resolved.md`로 이전됨).
---
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

@ -147,12 +147,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
재사용 — 구 `base.perInstanceState(inst)`/`PerInstanceState.luau`를
대체(2026-08-08 세션 신설).
- [ ] `LifetimeHandle.luau` **인터페이스만**(`bindLifetime(inst,value)`/
`unbindLifetime(value)`/`canExecute(value)` 탑레벨 함수
타입 계약, 실 구현 없음 — quad-roblox 실 구현은 M8) — 원래 M8에만
있었으나 M4(StoreBind의 `Connected` 확인)/M6(Slot의 `canExecute`)이
이미 이 인터페이스를 전제로 서술돼 있어 로드맵 순서가 역전돼
있었음(`pre-implementation-audit.md` 우선순위1-9, `question.md`
2번 — 2026-08-07 네 번째 세션에 반영).
`unbindLifetime(value)`/`canBound(value)`/`canExecute(value)` 탑레벨
함수 타입 계약, 실 구현 없음 — quad-roblox 실 구현은 M8) — 원래
M8에만 있었으나 M4(StoreBind의 `Connected` 확인)/M6(Slot의
`canExecute`)이 이미 이 인터페이스를 전제로 서술돼 있어 로드맵
순서가 역전돼 있었음(`pre-implementation-audit.md` 우선순위1-9,
`question.md` 2번 — 2026-08-07 네 번째 세션에 반영).
**[정정, 2026-08-14 다섯 번째 세션] `unbindLifetime`/`canExecute`는
`inst`를 안 받는다** — 옛 2-인자 시그니처(`(inst, value)`)는 오염이었음.
`bindLifetime`이 바인딩 시점에 `inst`의 gcconn 참조를 `value`
@ -167,13 +167,16 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`inst` 전체 죽기 전에 특정 값 하나만
조기 해제(`Dispatch.setLength`가 State 재등록 시 이전 Observer를
정리하는 데 씀), gchold 내부 구조를 호출부가 몰라도 되게 캡슐화.
`bindLifetime`/`unbindLifetime`/`canExecute` 셋 다 네임스페이스
없이 탑레벨 함수로 export(`Dispatch.xxx`류 시스템 네임싱과 구분,
`isState`/`isObserver`와 같은 1급 프리미티브 취급) — `base/
lifecycle-pattern.md`의 "`bindLifetime`/`canExecute`/`unbindLifetime`
— 확정" 절 참고. **이중 바인딩 금지 게이트도 `canExecute` 하나로
통합**(별도 `canBound`는 폐기 — M3 체크박스 참고), children 배열 leaf
부착이 실제로는 `bindLifetime` 호출이라 이 게이트를 그대로 탐
`bindLifetime`/`unbindLifetime`/`canBound`/`canExecute` 넷 다
네임스페이스 없이 탑레벨 함수로 export(`Dispatch.xxx`류 시스템
네임싱과 구분, `isState`/`isObserver`와 같은 1급 프리미티브 취급) —
`base/lifecycle-pattern.md`의 "`bindLifetime`/`canBound`/
`canExecute`/`unbindLifetime` — 확정" 절 참고. **이중 바인딩 금지
게이트는 `canBound`**(`canExecute`는 emit 전파 게이팅 전용 —
**[2026-08-14 열한 번째 세션] `canBound`가 별도 진입점으로 재도입되어
다시 갈라짐, 판정 로직은 공유하는 비공개 헬퍼 하나 — M3 체크박스
참고**), children 배열 leaf 부착이 실제로는 `bindLifetime` 호출이라
이 게이트를 그대로 탐
- [ ] `Dispatch.setLength(inst,i,len:number|State<number>)`/
`Dispatch.setOffsetSource(inst,i,offset:Source<number>|None)`
array part 형제 순서 보장(Length/Offset 누적합→`LayoutOrder` 리액티브
@ -271,30 +274,39 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
부르는 순수 설탕, `factory: (State<T>) -> U): U`로 열린 타입. Source도
기존 `:With`/`:Compute` 델리게이션에 얹혀 자동 포함
- [ ] `state:Observer(fn)` — children 배열 leaf 참가자, **등록 즉시 1회
실행 확정**(`base/bind-system-plan.md`의 Observer 절), `isObserver`
판별자, canExecute 게이팅, `:Subscribe()`/`:Unsubscribe()`
실행 확정**(`base/source-state-plan.md`의 Observer 절), `isObserver`
판별자, canExecute 게이팅, `:Subscribe()`/`:Unsubscribe()`. **동적
경로 가드**(`{priority = HANDLER_PRIORITY_FALLBACK, isHandlable = v
is Observer, process = error(...)}`, `k` 타입 안 가림, 2026-08-14
열한 번째 세션 — `PreRef`와 같은 패턴)도 같이 등록
- [ ] `Effect(fn, state?)`(`base/effect-plan.md`) — `state` 생략 시 설치
1회+leaf 사망 시 확정 정리, `state` 지정 시 내부적으로
`state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React
`useEffect` 동형). Observer 구현 이후에 착수(의존 관계).
`EffectHandle:Subscribe()`/`:Unsubscribe()`도 추가(leaf 없이 쓰는
모듈/스크립트 레벨 Effect) — `:Unsubscribe()`는 Observer와 달리
마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션)
- [ ] Observer/Effect 이중 바인딩 금지 — `canExecute(value)` 게이트로
마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션).
**동적 경로 가드**도 Observer와 같은 패턴으로 등록(`base/effect-plan.md`
"동적 경로 가드" 절, 2026-08-14 열한 번째 세션)
- [ ] Observer/Effect 이중 바인딩 금지 — `canBound(value)` 게이트로
`:Subscribe()`(전역)와 `bindLifetime`(inst-scoped, leaf 부착도
내부적으로 이걸 호출)이 동시에 걸리면 즉시 `error`(`base/source-state-plan.md` "이중 바인딩 금지" 절, 2026-08-07 일곱 번째
세션 신설, 2026-08-09 여섯 번째 세션에서 "leaf 부착=bindLifetime
호출"로 정정 — 진짜 독립 경로는 둘뿐).
**[역전, 2026-08-14 다섯 번째 세션] 별도 predicate `canBound(handle)`
(2026-08-09 세션에 이름 확정됐던 것)은 폐기** — "이미 유효하게 묶여
있다"와 "지금 실행 가능하다"가 정확히 같은 조건이라 `canExecute`
하나로 통합됨. `canBound`의 내부 근거로 지목돼 있던 `.Subscribed`
필드는 애초에 leaf 경로와 무관했고(전역 `:Subscribe()` 전용),
leaf 생존 판정은 `bindLifetime``value``Relate`에 복사해둔
gcconn으로 함 — `base/lifecycle-pattern.md`의 "`canBound` 폐기" 절,
역전 경위는 `archive/canexecute-inst-arg-reversed.md`. 부수 효과로
**바인딩이 죽은 뒤(`Destroy`/`unbindLifetime`)의 재사용은 게이트를
통과**(살아있는 바인딩만 막는 게 의도)
**[2026-08-14 다섯 번째 세션에 별도 predicate `canBound(handle)`
폐기하고 `canExecute` 하나로 합쳤다가, 같은 날 열한 번째 세션에
다시 갈라짐]** — "이미 유효하게 묶여 있다"(bound 문맥)와 "지금
발화해도 되는가"(execute 문맥)는 판정값은 같아도 호출부의 질문이
달라, `Ref` 이중 배치 방지(`question.md` 0-W)를 계기로 `canBound`
별도 진입점으로 재도입됨 — 판정 로직(비공개 `isBoundAlive` 헬퍼)은
공유해 코드 중복은 없음. **이 절이 쓰는 게이트는 이제 `canBound`**
(emit 전파 게이팅 전용 `canExecute`가 아님). `.Subscribed` 필드가
leaf 경로와 무관하다는 것, leaf 생존 판정을 `bindLifetime``value`
`Relate`에 복사해둔 gcconn으로 하는 것은 안 바뀜 — `base/
lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절, 역전 경위는
`archive/canexecute-inst-arg-reversed.md`. 부수 효과로 **바인딩이
죽은 뒤(`Destroy`/`unbindLifetime`)의 재사용은 게이트를 통과**
(살아있는 바인딩만 막는 게 의도, 안 바뀜)
- [ ] mock 대상 테스트
## M4 — 첫 end-to-end 반응형 업데이트
@ -588,6 +600,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
- [ ] `(v=Ref)` 매치 핸들러 — children 배열의 숫자 슬롯에 놓인
`Ref(default)` 인스턴스를 인식해 바인드(별도 `CreatedRef` 래퍼
없음 — 이름 자체가 폐기됨, 아래 참고)
- [ ] **이중 배치 방지**(`question.md` 0-W, 2026-08-14 열한 번째 세션
해소) — `RefLeafHandler.process`가 실제 바인딩 분기에서
`bindLifetime(inst, v)`를, 실제 언바인딩 분기에서 `unbindLifetime(v)`
호출. 새 `Relate` 불필요 — `bindLifetime`이 이미 내장한 `canBound`
이중 바인딩 가드를 재사용하는 것뿐(같은 `Ref`가 이미 다른 자리에
살아있으면 그 자리에서 즉시 error) — `base/ref-plan.md` "이중 배치
방지" 절
- [ ] `PreRef`/`PostRef` pre-pass — 새 `Dispatch.*` 함수 없이
`Dispatch.drive(inst, flattened)` 자신이 두 패스(배열→해시) 루프
전에 배열 파트를 **한 번** 훑어, `PreRef`는 그 자리에서 fire하고
@ -612,12 +631,19 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
완성은 보장하되 **이 인스턴스가 부모에 붙는 것보다는 먼저**임
`base/ref-plan.md` "`PostRef`" 절
- [ ] `PostRef` 동적 경로 가드 Handler — `PreRef`의 것과 완전한 거울상
(`{isHandlable = v is PostRef, process = error(...)}`), 같은 절 참고
- [ ] `PreRef` 동적 경로 가드 Handler — `{isHandlable = v is PreRef,
process = error(...)}` 형태로 정상 우선순위 레지스트리에 등록,
`NoneHandler`와 같은 "한 값 종류 전담" 패턴. 리터럴 배열 경로는
pre-pass가 이미 소진시키므로 이 Handler가 매치되면 곧 타입 차단을
우회한 버그라는 뜻 — 같은 절 참고
(`{priority = HANDLER_PRIORITY_FALLBACK, isHandlable = v is PostRef,
process = error(...)}`), 같은 절 참고
- [ ] `PreRef` 동적 경로 가드 Handler — `{priority =
HANDLER_PRIORITY_FALLBACK, isHandlable = v is PreRef, process =
error(...)}` 형태로 정상 우선순위 레지스트리에 등록(`k` 타입 안
가림), `NoneHandler`와 같은 "한 값 종류 전담" 패턴. 리터럴 배열
경로는 pre-pass가 이미 소진시키므로 이 Handler가 매치되면 곧 타입
차단을 우회한 버그라는 뜻 — 같은 절 참고. **[2026-08-14 열한 번째
세션]** 우선순위가 하드 블록이 아니라 `FALLBACK`인 이유(나중에 named
자리 바인드가 확정되면 평범한 우선순위 Handler로 덮어쓸 수 있게 —
`Tag`/`Attribute`와 같은 이유)와 `Observer`/`EffectHandle`에도 같은
패턴의 가드가 추가됨은 `base/source-state-plan.md`/`base/effect-plan.md`의
"동적 경로 가드" 절 참고
- [ ] **[2026-08-14 두 번째 세션 신설]** `ProcessedPreRefHandler` +
**[아홉 번째 세션] `ProcessedPostRefHandler`**(완전한 거울상, 코드
한 글자 차이) — `{isHandlable = v == Processed*Ref, process =
@ -635,20 +661,25 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`coroutine.running()` 캡처+yield, 있으면 등록만 하고 즉시 `self`
반환(남의 thread를 여기서 대신 정지시킬 수 없어서)
- [ ] `LifetimeHandle` quad-roblox 실제 구현 — `bindLifetime`/
`unbindLifetime`/`canExecute` 본체(인터페이스 자체는 M2로 이동됨,
`Relate` 자체는 quad-base라 quad-roblox 쪽 재구현 없음).
`unbindLifetime`/`canBound`/`canExecute` 본체(인터페이스 자체는
M2로 이동됨, `Relate` 자체는 quad-base라 quad-roblox 쪽 재구현
없음).
**[2026-08-14 다섯 번째 세션 정정] gcconn/gchold를 여기서 lazy 생성하지
않는다** — 생성은 M5의 Instance 생성 경로가 이미 끝내둔 것이고, 이
함수들은 `InstData`에서 찾아 쓰기만 함. `bindLifetime`
`gchold[value]=true`(강참조로 생존 보장)와 `BindData:SetWeak(value,
"gchold"/"gcconn", ...)`(값이 자기 생존 판정 근거를 직접 들고 있게)
둘만 하고, `unbindLifetime(value)`은 그 셋을 되돌림, `canExecute(value)`
복사된 gcconn의 `.Connected` 또는 `.Subscribed`를 봄.
둘만 하고, `unbindLifetime(value)`은 그 셋을 되돌림. **[2026-08-14
열한 번째 세션] `canBound(value)`/`canExecute(value)`는 비공개
헬퍼 `isBoundAlive(value)` 하나(복사된 gcconn의 `.Connected` 또는
`.Subscribed`를 봄)를 공유하는 얇은 진입점 둘로 분리** — `bindLifetime`/
`Observer:Subscribe()`의 이중 바인딩 가드는 `canBound`, State emit
전파 루프만 `canExecute`.
**저장은 전부 `SetWeak`**(`SetStrong` 아님 — gchold/gcconn은 아래 M5
클로저↔`gchold[1]` 상호 참조로 이미 안전하게 살아있고, "다른 곳에서
안전하게 유지되는 것은 항상 weak로 잡는다"가 일반 규칙).
`base/lifecycle-pattern.md`의 "`bindLifetime` / `unbindLifetime` /
`canExecute`" 절
`base/lifecycle-pattern.md`의 "`bindLifetime` / `canBound` /
`canExecute` / `unbindLifetime`" 절
## M9 — 컴포넌트 합성 레이어