fix(attribute,slot): retract 전면 재설계, 소유권 레지스트리 통합, 코퍼스 감사

- Attribute: AttributeKeyHandler.retract를 완전 no-op으로 재정정 —
  지우는 건 오직 명시적 None/nil(process)로만, 그룹이 사라진 이름을
  자동으로 SetAttribute(nil) 안 해줌(Ref의 "Destroy 무관" 철학과 통일).
  단 사라진 이름의 StoreBind 구독은 retractUnder로 끊어 리소스 누수 방지.
- Slot: slotOwner를 elementOwner로 일반화해 top-level Dispatch/nested
  Add 경로가 같은 소유권 레지스트리를 쓰도록 통합(이중 마운트 gap 폐쇄).
  bindLifetime을 top-level 전용으로 축소(nested는 _elements 강참조로
  transitively 생존).
- 일반 규칙 신설(bind-system-plan.md): retract의 v는 타입 미보장이라
  내용을 보려면 isX(v) 가드 필수, retract 안에서 process 호출은
  retractUnder 체인 추적을 꼬는 UB.
- and/or 삼항 관용구 전면 금지(기존 "항상-truthy면 예외" 조항 폐기),
  코퍼스 전체 실제 코드 6곳을 if-then-else로 교체.
- 7-에이전트 코퍼스 감사로 stale 서술 다수 정정: retract-always-fires
  정정 전파 누락(bind-system-plan.md Tag 예시), Tween research→base
  승격 반영 누락(architecture.md/ui-shorthand-plan.md), Relate API
  인자 개수 버그(slot-plan.md), pre-implementation-audit.md 열린 항목
  개수 오류(question.md/CLAUDE.md).
- 백로그: Attribute 자동 unset용 :Apply 유틸 아이디어를
  research/operator-sugar-plan.md에 추가(착수 안 함).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVG74qV2nQVykhvMRQW2UC
This commit is contained in:
qwreey 2026-08-12 18:39:45 +09:00
parent b91c662f50
commit 2df591851f
Signed by: qwreey
GPG key ID: D28DB79297A214BD
12 changed files with 510 additions and 178 deletions

View file

@ -178,8 +178,8 @@ quad/
**남은 것**: Slot 코어 로직의 정확한 API(`research`→`base` 승격된
`slot-plan.md` 참고)와 각 파일의 정확한 함수/타입 이름은 구현 단계에서.
Tween/existing-instance-bind는 여전히 `research/`에 남아있고 이 구조 확정을
막지 않음(`purity-and-effects-plan.md`는 이미 `base/`로 승격 완료).
existing-instance-bind는 여전히 `research/`에 남아있고 이 구조 확정을
막지 않음(`purity-and-effects-plan.md`/`tween-plan.md`는 이미 `base/`로 승격 완료).
## 코드 스타일 — 네이밍 케이싱 (2026-08-08 두 번째 세션 신설)
@ -227,9 +227,17 @@ Tween/existing-instance-bind는 여전히 `research/`에 남아있고 이 구조
표현식 문법(공식 릴리스 노트: <https://luau.org/news/2021-10-31-luau-recap-october-2021/#if-then-else-expression>)
`cond and truthyOnly or fallback` 삼항 관용구와 달리 가운데 값이
falsy(`nil`/`false`)여도 정확하게 동작함(`bind-system-plan.md`의
`Dispatch.retractUnder` 정정 사례가 실제 버그 예시). **이 프로젝트의
기본 삼항 표현 방식은 `if-then-else`** — `cond and x or y``x`
테이블/숫자처럼 항상-truthy임이 보장될 때만 예외적으로 허용.
`Dispatch.retractUnder` 정정 사례가 실제 버그 예시). **[강화, 2026-08-12
세션 후속] `cond and x or y` 삼항 관용구는 전면 금지 — `if-then-else`
쓸 것, 가운데 값이 항상-truthy임이 보장돼도 예외 없음.** 처음엔 그
경우만 예외적으로 허용했으나, 안전 여부와 무관하게 `if-then-else`
항상 더 낫다는 게 재확인됨 — `and`/`or`는 진짜 short-circuit이라 각
단계 truthiness를 테스트하는 분기가 최대 2번 들어가는데(`cond` 테스트 +
`and`의 결과 테스트), `if-then-else``cond` 하나만 테스트하고 단일
분기로 끝남 — 방어적이면서 바이트코드상으로도 더 적은 분기. 단순
2항 fallback(`x or y`, "and" 없이 값 하나를 기본값으로 대체하는
`props.Modifier or None`류)은 애초에 이 문제가 없어 그대로 유지 —
금지 대상은 어디까지나 `A and B or C` 3항 조합.
**`const` 바인딩도 Luau 공식 문법**(<https://luau.org/syntax/#const-bindings>)
이지만 **지금은 채택하지 않음** — 타입 추출/narrowing 등 주변 툴링이
@ -296,8 +304,8 @@ pull-recompute(`Get()` 시점) — Fusion식 eager 노드 없이도 다이아몬
## 아직 미정 (research/로 분리됨)
Tween 플러깅, 이미 생성된 인스턴스에 대한 바인드 — `.claude/research/`
문서 참고, 전체 색인은 `.claude/README.md`. 바인드 디스패치/Slot/모듈
이미 생성된 인스턴스에 대한 바인드 — `.claude/research/existing-instance-bind-plan.md`
참고, 전체 색인은 `.claude/README.md`. 바인드 디스패치/Slot/모듈
라이프사이클/Modifier/컴포넌트화(컴포넌트 경계 modifier/Ref 전달 포함)는
위 "구현 착수" 섹션대로 확정되어 `.claude/base/`로 승격됨
(`bind-system-plan.md`/`module-lifecycle-plan.md`/`slot-plan.md`/

View file

@ -1,7 +1,9 @@
# Attribute — 단일 키(`AttributeKey`)와 그룹(`Attribute(...)`) 두 프리미티브
**상태**: base — 단일 키 메커니즘/`None`/`retract` 동작과 타입 파라미터화는
전부 확정(2026-08-09 열한 번째 세션). **[2026-08-11 아홉 번째 세션 추가]**
전부 확정(2026-08-09 열한 번째 세션, **2026-08-12 세션 후속에서
`retract` 완전 no-op화 + 그룹 청소 정책 전면 재정정 — 아래 "메커니즘"/
"그룹 `Attribute(...)`" 절이 최신**). **[2026-08-11 아홉 번째 세션 추가]**
Store 여러 개를 한 번에 attribute로 묶어 바인드하는 그룹 `Attribute(...)`
프리미티브 신설, 이름 충돌 방지를 위해 기존 단일 키 생성자를
`Attribute<<T>>``AttributeKey<<T>>`로 리네임(잠정 확정 — 최종 이름은
@ -103,24 +105,30 @@ end
타입 파라미터화 이름과 무관하게 런타임 동작은 확정:
- `process(inst, k, v)``inst:SetAttribute(name, v)`가 사실상 전부.
**Attribute는 `None`의 가장 깔끔한 사례** — Roblox API 자체가
`SetAttribute(name, nil)`을 "그 Attribute 엔트리를 지운다"는 뜻으로
네이티브 지원하므로, `None → nil` 재디스패치(`base/bind-system-plan.md`의
`None` 센티널 절)가 도착했을 때 handler가 **아무 특별 처리도 없이**
`inst:SetAttribute(name, nil)`을 그대로 호출하면 끝 — UICorner 숏핸드처럼
"만들어둔 자식을 수동으로 찾아 지우는" 로직조차 필요 없음.
- **`retract` 불필요** — Tag와 같은 이유: 값이 뭐든(실제 값/`nil`) 항상
같은 `AttributeKeyHandler`가 이 키를 계속 담당(핸들러 *타입*이 안 바뀜).
`retract`가 의미 있는 유일한 패턴("매치되는 핸들러 타입 자체가 바뀜",
`Tag(...)`↔`nil`이 실사례 — 2026-08-10 세션부터 Tween은 더 이상 이
패턴의 예시가 아님, `base/tween-plan.md`)에 해당 안 함 —
`bind-system-plan.md` "확정된 디스패치 모델" 절이 한때 Attribute도
retract 필요 예시로 들었던 걸 여기서 바로잡음. **[정정, 2026-08-12
열 번째 세션] 그룹 위임 경로가 생기면서 `retract`가 다시 의미 있어짐 —
아래 "이름 소유권" 절 참고, 이 문단이 말하는 "값이 뭐든 같은 핸들러"는
여전히 맞지만 그룹이 이름을 놓을 때는 그 이름 전용 키 객체 자체가
통째로 폐기되므로 그 키 슬롯 기준으로는 `retract`가 실행됨.**
- `process(inst, k, v)``inst:SetAttribute(name, v)`가 사실상 전부,
**`v`가 뭐든(실제 값이든 `nil`이든) 무조건 그대로 호출** — 일반
프로퍼티 핸들러와 완전히 동일한 무조건 set. **Attribute는 `None`
가장 깔끔한 사례** — Roblox API 자체가 `SetAttribute(name, nil)`
"그 Attribute 엔트리를 지운다"는 뜻으로 네이티브 지원하므로, `None →
nil` 재디스패치(`base/bind-system-plan.md`의 `None` 센티널 절)가
도착했을 때 handler가 **아무 특별 처리도 없이** `inst:SetAttribute(name,
nil)`을 그대로 호출하면 끝 — UICorner 숏핸드처럼 "만들어둔 자식을
수동으로 찾아 지우는" 로직조차 필요 없음.
- **`retract`는 완전 no-op — [재정정, 2026-08-12 세션 후속] "매번
불리지만 대부분 no-op"이라던 직전 서술도 틀렸음, "대부분"이 아니라
"항상"** — 일반 프로퍼티 핸들러(`retract`가 완전 무조건 no-op,
`bind-system-plan.md` "일반 프로퍼티는 애초에 'unset' 개념이 없음")와
완전히 같은 성격으로 재정정. **`AttributeKeyHandler.retract`
`SetAttribute`를 절대 호출하지 않음** — attribute를 지우는 유일한
경로는 `process(inst,k,nil)`(`None`이든, State가 스스로 `nil`
바뀌든) 뿐. 이전 버전("이름이 사라질 때(`v==nil`)만 retract가
`SetAttribute(name,nil)`을 호출")은 두 가지 문제가 있었음 — (1)
`retract` 안에 관측 가능한 부작용이 생겨 `bind-system-plan.md`
"retract는 구조적 팝만, process 트리거 금지" 일반 규칙과 어긋나는
성격의 코드가 됨, (2) 그룹이 survivor 이름에 `retractUnder(...,source)`
부를 때 그 시점에 `SetAttribute`가 잘못 끼어들 수 있는 경로가 생겨
`a→nil→b` 깜빡임 위험(사용자 지적) — `retract`가 완전 no-op이면 이
경로 자체가 물리적으로 없어짐.
- store-bind 가능(일반 프로퍼티와 동일하게 취급, `Store<T>`/`State<T>`
값도 받음).
@ -154,28 +162,17 @@ function AttributeKeyHandler.process(inst, k, v)
if current ~= nil and current ~= k then
error(("attribute \"%s\"는 이미 다른 AttributeKey가 관리 중"):format(name))
end
inst:SetAttribute(name, v)
if v == nil then
map[name] = nil -- nil로 귀결되면 소유권도 같이 반납 — 다른 claimant가 다시 쓸 수 있게
else
map[name] = k
end
inst:SetAttribute(name, v) -- v가 nil이든 아니든 무조건 — 일반 프로퍼티와 완전히 동일
map[name] = if v == nil then nil else k -- nil로 귀결되면 소유권도 같이 반납
owners:SetStrong(inst, map)
end
function AttributeKeyHandler.retract(inst, k, v)
-- [정정, 2026-08-12 열한 번째 세션] retract는 이 k가 store 재발행으로
-- 다시 process될 때도 매번 불림(핸들러 타입이 안 바뀌어도) —
-- bind-system-plan.md 일반 retract 계약 절 참고. v가 nil이 아니면
-- 곧바로 process가 재확정하므로(같은 k가 계속 살아있는 한 항상
-- 자기 자신이 다시 매치됨) 여기서 SetAttribute/소유권 반납을 할
-- 이유가 없음 — v가 nil일 때만(진짜 이 이름이 사라지는 경우) 실행.
local name = k.Name
local map = owners:GetStrong(inst)
if map and map[name] == k and v == nil then
map[name] = nil
inst:SetAttribute(name, nil)
end
-- 완전 no-op. 일반 프로퍼티와 동일 — "unset" 개념 자체가 없음.
-- retract 안에서 process를 부르는 건 Dispatch.retractUnder의 체인
-- 추적을 꼬는 UB(base/bind-system-plan.md 일반 규칙)라 여기서도
-- SetAttribute를 직접이든 간접이든 절대 안 부름 — 지우는 건 오직
-- process(inst,k,nil).
end
```
@ -188,22 +185,10 @@ end
새 릴레이션 불필요, 이미 있던 걸 재사용. 이름을 처음 보면 `rawNew(name)`
만들어 이 맵에 캐싱하고 그 키로 위임, 이미 맵에 있으면(이전 사이클에
이미 관리 중이던, 즉 "남아있는" 이름) **그 캐싱된 같은 객체를 그대로
재사용**해서 위임.
- **[정정, 2026-08-12 열한 번째 세션] "남아있는 이름은 retract 자체가 안
불린다"는 예전 서술은 틀렸음** — `AttributeKeyHandler.retract`는 이제
`v==nil`일 때만 실제로 뭔가 하므로(위 "메커니즘, `None`, `retract`" 절
정정분), 남아있는 이름에 대해서도 `retractUnder`를 불러도 안전하고 —
오히려 **불러야 함**: `Dispatch.process`가 매번 체인 꼬리에 새 항목을
쌓기만 하지 스스로 옛 항목을 안 지우므로(popping은 `retractUnder`
자신의 일), 남아있는 이름을 `retractUnder` 없이 `Dispatch.process`
반복 호출하면 그룹 값이 교체될 때마다 같은 `(inst,key)` 체인에 옛
`AttributeKeyHandler` 항목이 계속 쌓이는 누수가 생김. **그래서 그룹의
diff는 사라진/남아있는 이름 모두 자기 캐싱된 키로 먼저
`Dispatch.retractUnder(inst, key, nil, newSourceOrNil)`를 부른 뒤에만
`Dispatch.process`를 부름** — 새로 들어온 이름만 예외(옛 체인이 없으니
retractUnder 없이 바로 process). `AttributeKeyHandler.retract``v`
non-nil이면 즉시 return하는 얇은 함수라 이 추가 호출의 비용은 무시할
수준.
재사용**해서 위임. 이 diff/재위임 로직의 정확한 코드는 아래 "그룹
`Attribute(...)`" 절의 `AttributeGroupHandler.process`/`.retract` 참고
`AttributeKeyHandler` 자신은 diff를 전혀 모름(위 "이름이 살아있는
동안 항상 같은 키 재사용" 전제만 지켜지면 그만).
- **패키지 경계**: `AttributeKey` 자체가 이미 quad-roblox 소속(Tag와
달리 base/roblox로 안 쪼갬, 아래 "패키지 배치" 절)이고 그룹의 실제
위임 로직도 이미 roblox 쪽 글루라 `rawNew` 호출이 새 역의존을 안 만듦 —
@ -257,59 +242,95 @@ Dispatch 재진입 없이 직접 `SetAttribute`+수동 per-field StoreBind 구
위 "이름 소유권" 절 참고, 이 절은 그 위에서 diff 로직이 어떻게 도는지만
설명:
- **그룹 값이 (재)할당될 때**(마운트 시점, 또는 `:Compute`가 그룹 값
자체를 교체하는 드문 경우 — 흔한 필드 하나 변경과는 다른 경로, 아래
참고): `(inst, index)`(array-part 위치, `Tag``relate:GetStrong(inst,k)`
동일 키잉 — `k`는 배열 인덱스)로 찾은 릴레이션에 저장된 **"이전에 쓴
attribute 이름 → 그 이름 전용 키 객체 맵"**(구 "이름 문자열 집합" —
이름 존재 여부뿐 아니라 그때 쓴 키 객체 자체까지 같이 들고 있어야 위
"이름 소유권" 절의 동일 객체 재사용이 성립)과 새 값의 키 집합을 diff:
- **[정정, 2026-08-12 열한 번째 세션] 사라진 이름뿐 아니라 남아있는
이름도 먼저 `Dispatch.retractUnder(inst, key, nil, source)`를 부른
뒤에야 `Dispatch.process`를 부름** — 그 이름이 맵에 들고 있던 **그
전용 키 객체**로, 그 이름이 살아있는 동안 만들어졌던 원래 체인과
정확히 같은 슬롯을 가리켜 정리(팝)됨. `retractUnder` 없이
`Dispatch.process`만 반복 호출하면 체인이 매번 새 항목을 쌓기만 해서
(팝은 `retractUnder`의 일) 같은 키 자리에 옛 `AttributeKeyHandler`
항목이 계속 누적되는 진짜 누수가 생기므로, "값이 안 바뀌었으니
retract 생략"은 성립 안 함 — `AttributeKeyHandler.retract``v`
non-nil이면 즉시 return하는 얇은 함수라(위 "이름 소유권" 절) 이
호출 자체는 사실상 공짜.
- 사라진 이름: `Dispatch.retractUnder(inst, key, nil, nil)` — 뒤이어
`process` 안 부름, 맵에서도 그 이름 제거.
- 남아있는 이름: `Dispatch.retractUnder(inst, key, nil, source)`
바로 `Dispatch.process(inst, key, source)` — **맵에 이미 캐싱된 같은
키 객체를 그대로 재사용**.
- 새로 들어온 이름: 옛 체인이 없으니 `retractUnder` 없이 바로
`rawNew(name)`로 갓 만든 키를 맵에 새로 캐싱하고
`Dispatch.process(inst, key, source)`.
- **값 비교 없이 전부 재위임** — 작성자가 직접
`[AttributeKey<<T>> name] = source`를 쓴 것과 거의 같은 경로를
타므로(단, 공개 캐시가 아니라 그룹 전용 키를 씀) `source`
`State`/`Source`면 `Dispatch/StoreBind`가 알아서 언랩+구독까지 다
해줌(그룹 Handler가 따로 구독 관리 안 함).
- **값 비교(`:Get()`으로 old/new 비교)는 하지 않음** — State 계약("값은
항상 선언된 Compute 재실행 결과, 캐시 비교 금지", `store-semantics.md`
"하드 경계" 절)과 어긋나고, 릴레이션 키를 문자열 이름 집합(존재 여부)만
들고 있으면 충분해서 굳이 값까지 비교할 이유가 없음.
- **캐시(맵)는 그룹 값 교체를 넘어 계속 유지돼야 함** — 매 교체마다
남아있는 이름의 키까지 새로 만들면, "이름 소유권" 절의 `owners`
레지스트리엔 옛 키가 남아있어 새로 만든 키와 비교 시 오탐 충돌이
남(자기 자신과 충돌하는 꼴). 이 릴레이션이 `(inst, index)`
영속되는 건 이미 확정돼 있던 설계라 새로 챙길 것 없음, 저장 값
형태만 위처럼 바뀜.
**그룹의 `process`/`retract` — [전면 재정정, 2026-08-12 세션 후속]**
아래는 `(inst, index)`(array-part 위치, `Tag``relate:GetStrong(inst,k)`
동일 키잉 — `k`는 배열 인덱스)로 찾은 릴레이션에 저장된 **"이름 →
그 이름 전용 키 객체 맵"**(이름 존재 여부뿐 아니라 그때 쓴 키 객체
자체까지 같이 들고 있어야 위 "이름 소유권" 절의 동일 객체 재사용이
성립)을 씀:
```lua
local groupState = Relate() -- {[inst(weak)] = {[index]: {[name]: keyObject}}}
function AttributeGroupHandler.process(inst, index, v)
if v == nil then return end
local map = groupState:GetStrong(inst, index) or {}
for name, source in pairs(v:NameMap()) do
local key = map[name] or rawNew(name) -- 남아있던 이름은 캐싱된 같은 객체 재사용
map[name] = key
Dispatch.retractUnder(inst, key, nil, source) -- chain-append-leak 방지, 매번(신규는 빈 체인이라 no-op)
Dispatch.process(inst, key, source)
end
groupState:SetStrong(inst, index, map)
end
function AttributeGroupHandler.retract(inst, index, v)
local map = groupState:GetStrong(inst, index)
if not map then return end
local newNames = if isAttribute(v) then v:NameMap() else {}
for name, key in pairs(map) do
if newNames[name] == nil then -- 새 v에 이제 없는 이름만
Dispatch.retractUnder(inst, key, nil, nil) -- 구독만 끊음 — SetAttribute는 안 일어남(아래 원칙)
map[name] = nil
end
end
end
```
- **`process`는 매번 살아있는 이름 전부를 `retractUnder`+`process`
페어로 재위임** — 신규/생존 구분 없이 균일 처리. `Dispatch.process`
매번 체인 꼬리에 새 항목을 쌓기만 하지 스스로 옛 항목을 안 지우므로
(팝은 `retractUnder`의 일), `retractUnder` 없이 `Dispatch.process`
반복 호출하면 같은 키 자리에 옛 `AttributeKeyHandler` 항목이 계속
쌓이는 누수가 생김 — 신규 이름은 아직 체인이 없어 `retractUnder`
그냥 no-op이라 이 페어링을 신규/생존 가리지 않고 통일해도 비용 없음.
**값 비교(`:Get()`으로 old/new 비교)는 안 함** — State 계약("값은 항상
선언된 Compute 재실행 결과, 캐시 비교 금지", `store-semantics.md`
"하드 경계" 절)과 어긋나고, `source``State`/`Source`면
`Dispatch/StoreBind`가 알아서 언랩+구독까지 다 해줌(그룹 Handler가
따로 구독 관리 안 함)이라 굳이 비교할 이유가 없음.
- **[확정, 2026-08-12 세션 후속, 사용자 결정] `retract``SetAttribute`
절대 안 부름 — Attribute는 오직 명시적 `None`/`nil`로만 지워진다.**
그룹에서 이름이 조용히 빠지든(diff로 사라짐), 그룹 바인딩 자체가
통째로 사라지든(컴포넌트 언마운트 등, `v`가 더 이상 Attribute가 아님)
프레임워크가 자동으로 `SetAttribute(name,nil)`을 대신 불러주지
않음 — 값이 이전 것 그대로 남는 게 정상 동작. `Ref`가 Destroy와
무관하게 동작하는 것과 같은 철학("지울 거면 명시적으로 지우라",
`bind-system-plan.md`의 "`Ref`의 retract" 절)으로 통일. **이전 초안은
"Tag와 동일하게 확실히 청소"였으나 뒤집힘** — 이유: (1) diff로
조용히 빠지는 이름은 안 지워주면서 통째 소멸일 땐 지워주면, 두 경우가
서로 다른 규칙이 되어 오히려 모호해짐(사용자 지적: "diff 쌓인 거랑
전부 지운 거랑 완전 달라짐"). (2) Attribute 이름은 이미 "겹치면
error"로 소유 코드가 명확히 갈리는 설계(위 "이름 소유권" 절)라, 그
이름을 만든 코드가 알아서 지우는 게 맞지 프레임워크가 대신 판단할
이유가 불투명함. (3) 정말 자동 청소가 필요하면 `Animate`와 같은 모양
(`State<data> -> State<Attribute>`를 만드는 `:Apply` 팩토리, 이전
그룹과 비교해 사라진 이름을 `None`으로 명시적으로 채워 넣는 유틸)을
나중에 opt-in으로 추가하면 됨 — 그건 사용자가 고른 명시적 선택이라
모호하지 않음, 지금은 범위 밖(백로그).
- **다만 사라진 이름의 *구독*은 끊음 — 값은 안 지워도 자원은 새면
안 됨.** 위 "값은 안 지운다" 원칙과 별개로, 그룹이 더 이상 관리하지
않는 이름의 `(inst,key)` 체인을 그대로 두면 그 키에 걸려있던
`StoreBind` 구독이 인스턴스가 살아있는 동안 영원히 남아 원본
`Source`가 바뀔 때마다 계속 `SetAttribute`를 쏘는 실제 리소스 누수가
됨(이건 "마지막 값이 남는다"는 것과 다른 문제 — 안 죽는 구독 자체가
문제). 그래서 `retract`는 사라진 이름에 한해 `Dispatch.retractUnder(inst,
key, nil, nil)`만 부름 — **`Dispatch.process`는 절대 안 부르므로**
(retract 안에서 process 호출은 `retractUnder`의 체인 추적을 꼬는 UB,
`bind-system-plan.md` 일반 규칙) `AttributeKeyHandler.retract`(완전
no-op)만 타고 끝나 `SetAttribute`는 여기서도 절대 안 일어남 — 위
"명시적 None으로만 지운다" 원칙과 안 부딪힘.
- **필드 하나만 바뀌는 흔한 경우**(`storeA.foo:Set(v)`, 그룹 자체는
안 바뀜)는 위 그룹 재처리를 아예 거치지 않음 — 마운트 시 이미 걸린
단일 키 `AttributeKeyHandler`의 store-bind 구독이 바로
`SetAttribute("foo", v)`를 호출(그룹 재진입 없이 그 경로 스스로).
**`AttributeChanged`/`GetAttributeChangedSignal` 남발 걱정 없음** —
키 집합이 안 바뀌는 한 diff 로직 자체가 안 돎.
- **`retract`**: 자기가 쓴 키(전용 키 객체) 전부 `Dispatch.retractUnder`
정리 — 단일 키의 `SetAttribute(name, nil)`이 그대로 실행되므로 결과적으로
`Tag`와 동일하게 확실히 청소됨. Roblox는 Instance 풀링/재사용이 흔해서,
이전 컴포넌트가 남긴 attribute가 재사용된 Instance에 방치되면
selector/스타일시트 로직에 실제 버그가 됨. ("소진돼도 부작용 없으니
방치해도 된다"는 초안은 기각 — Tag의 확실한 청소 원칙과 통일.)
- **캐시(맵)는 그룹 값 교체를 넘어 계속 유지돼야 함** — 매 교체마다
남아있는 이름의 키까지 새로 만들면, "이름 소유권" 절의 `owners`
레지스트리엔 옛 키가 남아있어 새로 만든 키와 비교 시 오탐 충돌이
남(자기 자신과 충돌하는 꼴). 이 릴레이션이 `(inst, index)`로 영속되는
건 이미 확정돼 있던 설계.
**그룹 Handler에 남는 자기 로직은 사실상 "이름 집합 diff"뿐** — 실제
`SetAttribute` 호출/`None` 처리/store-bind 구독은 전부 기존 단일 키
@ -353,3 +374,10 @@ UICorner 숏핸드/Tween/Tag와 같은 판단 재사용 — `quad-roblox` 코어
당장의 해석 모호성은 없앴음 — 그래도 최종 이름은 다른 가칭들(`State`/
`DI`→`D`/`Slot`/`canExecute`/`Brand`)과 함께 `.claude/question.md` 용어정리
대기열에 있음, 나중에 한꺼번에 재검토.
- **[백로그, 2026-08-12 세션 후속]** 그룹이 이름을 조용히 놓아도
`SetAttribute(name,nil)`을 자동으로 안 해준다는 위 "그룹 `Attribute(...)`"
절의 결정 — 그래도 명시적 자동 unset이 갖고 싶으면 `Animate`와 같은
모양의 `:Apply` opt-in 유틸(이전 이름 집합과 비교해 사라진 이름을
`None`으로 채워주는 콤비네이터)을 나중에 추가할 수 있음, 착수 안 함 —
`research/operator-sugar-plan.md` "Attribute 그룹 명시적 unset 유틸"
절 참고.

View file

@ -47,12 +47,13 @@ v1의 `ProcessQuadProperty`(`.claude/initreq/quad/src/class.lua:134-214`)는
없음(예: 일반 프로퍼티 핸들러는 보통 no-op).
**`retract` 필드 자체는 생략 불가, no-op이라도 항상 정의할 것(2026-08-08
세션, 확정)** — `Dispatch.process`(아래 "확정된 디스패치 모델" 절)는
담당 핸들러 *타입*이 바뀔 때 이전 핸들러의 `retract`를 nil 체크 없이
무조건 호출함. 필드를 생략한 핸들러가 나중에(드물더라도, 예: `Tag(...)`
`nil` 교체 — `base/tag-plan.md`) 실제로 담당이 바뀌는 순간 `attempt to
call a nil value`로
바로 크래시 — "의미 있게 구현할 필요 없음"은 "구현이 사소해도 됨"이라는
뜻이지 "필드를 안 둬도 안전하다"는 뜻이 아님. 새 핸들러를 짤 때 이 필드가
store 재발행마다(핸들러 *타입*이 안 바뀌어도) 이전 핸들러의 `retract`
nil 체크 없이 무조건 호출함(`[전면 정정, 2026-08-12 열한 번째 세션]`,
아래 "retract-always-fires" 정정 절 참고 — 담당 타입이 바뀔 때만
불린다는 건 예전의 틀린 가정이었음). 필드를 생략한 핸들러는 이 반복
호출에서 바로 `attempt to call a nil value`로 크래시 — "의미 있게
구현할 필요 없음"은 "구현이 사소해도 됨"이라는 뜻이지 "필드를 안 둬도
안전하다"는 뜻이 아님. 새 핸들러를 짤 때 이 필드가
없으면 리뷰/린트에서 걸러내야 함(M2 착수 시 확인 목록에 추가).
디스패치는 등록된 핸들러를 우선순위 순으로 스캔하며 `isHandlable`을 호출,
@ -155,6 +156,22 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
패턴이 아님 — 상세 구현은 `base/tag-plan.md`/`base/attribute-plan.md`
"이름 소유권" 절, `Ref`는 아래 "`Ref`의 retract" 절, `Slot`
`slot-plan.md` "Slot과 Store 바인드의 관계" 절 참고.
- **[일반 규칙, 2026-08-12 세션 후속] `retract``v`는 타입을 보장 안
함 — 내용(메소드/필드)을 보려면 반드시 `isX(v)` 가드부터.** `retract`
`old ~= v`/`v == nil`처럼 identity/nil만 비교하면 가드가 필요 없지만
(`Ref`/`Slot`/`AttributeKey`가 이 경우), `v`의 내용을 실제로 들여다봐야
하면(`TagHandler.retract`의 `newv:Contains(name)`처럼) 그 전에 반드시
`isTag(newv)` 같은 타입 가드를 거칠 것 — 안 그러면 그 자리 핸들러
*타입*이 바뀌는 드문 경우에 엉뚱한 값의 메소드를 호출해 크래시함.
(`AttributeGroupHandler.retract`가 이 가드를 처음엔 빠뜨렸던 실수,
`base/attribute-plan.md` 참고.)
- **[일반 규칙, 2026-08-12 세션 후속] `retract` 안에서 `Dispatch.process`
부르는 것은 UB — `Dispatch.retractUnder`가 체인을 걷는 도중의 트래킹이
꼬임.** `retract`는 오직 청소(구조적 팝, 내부 자원 해제)만 전담하고
새 등록을 트리거하면 안 됨 — 새 등록은 항상 바깥의 StoreBind/그룹
로직이 `retract` 호출이 다 끝난 *뒤에* 별도로 `process`를 부르는
순서로만 일어나야 함. `Dispatch.retractUnder`를 (다른 키에 대해)
`retract` 안에서 부르는 건 문제없음 — 금지되는 건 오직 `process`.
- Tween은 이 패턴과 무관 — 독립 Dispatch 핸들러가 아니라 PropertyHandler가
소비하는 값-레벨 래퍼(`Tween<T>`)라 매치되는 핸들러가 항상
PropertyHandler 하나뿐(2026-08-10 세션 재설계) — 트윈 취소/전환은
@ -411,15 +428,20 @@ function Dispatch.retractUnder(inst, k, keep, v)
end
```
**[2026-08-12 세션에서 정정]** `list[i].retract(...)`의 세 번째 인자가
원래 `i == cutoff + 1 and v or nil`(and/or 삼항 관용구)이었으나, `v`
`false`일 때(정당한 boolean 프로퍼티 값) `and`의 결과가 falsy가 되어
`i == cutoff + 1`이 참이어도 `or nil`로 새는 조용한 버그였음 — Luau의
`if-then-else` 표현식(2021년 도입)으로 교체. **일반 규칙**: `cond and
truthyOnly or fallback` 관용구는 가운데 값이 테이블/항상-truthy 값일
때만 안전(예: `Tag(...)`/`Length:Get()`처럼 절대 `nil`/`false`가 될 수
없는 값) — 임의 `T`(boolean 포함) 값이 가운데 올 수 있으면 반드시
`if cond then a else b`를 쓸 것.
**[2026-08-12 세션에서 정정, 같은 세션 후속으로 예외 조항까지 폐기]**
`list[i].retract(...)`의 세 번째 인자가 원래 `i == cutoff + 1 and v or
nil`(and/or 삼항 관용구)이었으나, `v``false`일 때(정당한 boolean
프로퍼티 값) `and`의 결과가 falsy가 되어 `i == cutoff + 1`이 참이어도
`or nil`로 새는 조용한 버그였음 — Luau의 `if-then-else` 표현식(2021년
도입)으로 교체. **일반 규칙(강화): `cond and x or y` 삼항 관용구는
전면 금지, `if cond then x else y`만 쓸 것** — 처음엔 "가운데 값이
테이블/항상-truthy일 때만 예외적으로 and/or 허용"이었으나, 안전 여부와
무관하게 `if-then-else`가 항상 우월하다는 게 재확인돼 예외 자체를
없앰: `and`/`or`는 진짜 short-circuit이라 각 단계마다 truthiness를
테스트하는 명령이 들어가는데(최대 2회 분기), `if-then-else``cond`
하나만 테스트하고 단일 분기로 끝남 — 안전한 경우에도 `if-then-else`
쪽이 바이트코드상 더 적은 분기. `base/architecture.md`의 "코드 스타일
— Luau 문법 관례" 절도 같이 갱신.
- **`handler.process(inst,k,v)``Dispatch.process`를 거치지 않고 직접
호출하는 것은 UB — 반드시 `Dispatch.process`를 통해서만 진입할 것.**
@ -450,13 +472,18 @@ truthyOnly or fallback` 관용구는 가운데 값이 테이블/항상-truthy
- **`retract`는 여전히 `(inst,k,v)` 3-인자** — 드롭하자는 제안이 대화
중 한 번 나왔으나 기각(전체 삭제 vs 부분 diff를 갈라야 하는 핸들러가
있어서, `base/tag-plan.md` 참고). 다만 `v`가 실제로 필요한지는
핸들러마다 다름 — Tag는 구조상 retract가 "더 이상 매치 안 될 때만"
불리므로 `v`를 안 봐도 항상 전체 삭제가 맞음(무조건) — `v`는 "계약상
항상 주어지지만 안 쓰는 핸들러가 있어도 됨" 정도로 이해할 것.
핸들러마다 다름 — **[정정, 2026-08-12 열한 번째 세션]** Tag는 오히려
반대로 `v`를 반드시 봐야 하는 대표 사례다: `retract`는 store
재발행마다(핸들러 타입이 안 바뀌어도) 항상 불리므로, `TagHandler.retract`
`v`가 여전히 그 이름을 `Contains`하는지 힌트로 확인해 실제 엔진
`RemoveTag` 호출만 skip한다 — "더 이상 매치 안 될 때만 불리므로 무조건
전체 삭제가 맞다"는 원 서술은 이 정정 전 잘못된 가정이었음, 상세는
`base/tag-plan.md` "메커니즘" 절 참고. `v`는 "계약상 항상 주어지지만
안 쓰는 핸들러가 있어도 됨" 정도로 이해할 것.
**[정정, 2026-08-10 세션]** 원래 두 번째 예시로 들었던 Tween(자기
`Relate` 저장분만 보고 `Cancel`하면 되니 `v`를 꼭 안 봐도 됨)은 더
이상 유효한 예시가 아님 — PropertyHandler가 항상 매치되는 유일한
핸들러가 되어 이 `retract` 경로 자체가 사실상 안 쓰임(`research/
핸들러가 되어 이 `retract` 경로 자체가 사실상 안 쓰임(`base/
tween-plan.md`).
- **순환은 UB, 방어 로직 없음** — Handler 간 순환 참조(A가 B를 부르고
B가 다시 A로 돌아오는 것)는 재귀 호출이 안 끝나 바로 스택오버플로가
@ -624,7 +651,7 @@ local function recompute(ownerKey, bk)
offset:Set(sum)
end
local v = bk.lengthList[i]
sum += (isState(v) and v:Get() or v)
sum += (if isState(v) then v:Get() else v)
end
if isSlot(ownerKey) and ownerKey.Length:Get() ~= sum then
ownerKey.Length:Set(sum) -- ownerKey가 물리 inst가 아니라 Slot 자신인 재귀 케이스

View file

@ -33,8 +33,13 @@ weak여야 함 — 그런데 그 안에 담기는 값은 경우에 따라 **강
## 위험한 패턴 — 서로 다른 두 `Relate`의 상호 강참조 순환 (2026-08-12 열세/열네 번째 세션, `Slot` 설계 중 발견)
위 26-28행이 말하는 "값이 자기 키를 다시 참조하는" 자기참조(예:
`Dispatch.setLength``observer` 클로저가 `inst`를 캡처, `Ref.Value=inst`)는
**단일 `Relate` 안에서** 일어나는 한 안전함 — 그 `Relate`의 키(`inst`)가
`Dispatch.setLength``observer` 클로저가 `inst`를 캡처, `Ref.Value=inst`,
**[확인, 2026-08-12 세션 후속] `slot._mountedInst = physicalTarget`
같은 패턴** — `bindLifetime(physicalTarget, slot)`이 내부적으로
`physicalTarget`을 키로 `slot`을 강하게 붙잡는 단일 `relate`고,
`slot._mountedInst`는 그 값(`slot`)이 자기 키(`physicalTarget`)를 다시
참조하는 필드일 뿐이라 GC 안전 — `base/slot-plan.md` "재귀 메커니즘"
절)는 **단일 `Relate` 안에서** 일어나는 한 안전함 — 그 `Relate`의 키(`inst`)가
테이블 *바깥에서* 독립적으로 reachable한지만 판별하면 되기 때문. 하지만
**서로 다른 두 `Relate`가 서로의 키를 상대방 값으로 강하게 제공하는
상호 순환**(예: `RelateA[inst]=value`(강)와 `RelateB[value]=inst`(강)가

View file

@ -138,10 +138,14 @@ Fusion의 `Children` SpecialKey는 이걸 "특정 SpecialKey 하나의 내부
마운트 이후에 호출되는 경우 이 값으로 즉시 활성화(아래 "`Slot:List(...)`"의
"구독 시점" 절 참고).
- **개별 element**: Slot 안에 담기는 각 element(Instance/컴포넌트 결과 등)
마다 전역 weak-set 멤버십으로 추적 — 특정 Slot 인스턴스에 안 묶임
("한 인스턴스가 어디에도 중복 마운트 안 됨"이 라이브러리 전역 불변식이라서).
`Add`가 이 weak-set을 확인(이미 참이면 error)/설정, `Remove`/`Extract`
둘 다 여기서 제거(둘의 차이는 파괴 여부일 뿐, "마운트 해제"라는 점은 같음).
마다 전역 멤버십으로 추적 — 특정 Slot 인스턴스에 안 묶임("한 인스턴스가
어디에도 중복 마운트 안 됨"이 라이브러리 전역 불변식이라서). **[구체화,
2026-08-12 열여섯 번째 세션]** 이 전역 멤버십의 실제 구현은 아래
"요소 소유권 — `elementOwner`" 절의 `claimOwner`/`releaseOwner` —
`Add``claimOwner`(이미 다른 owner면 error)/`Remove`·`Extract`가
`releaseOwner`. top-level Dispatch 마운트(`SlotHandler`)도 **같은**
레지스트리를 써서 두 경로가 서로의 소유권을 볼 수 있음(전에는 별도
레지스트리라 안 보였던 gap, 해당 절 참고).
## 여럿 존재 가능, 부모가 실제 데이터 테이블만 다루면 됨
@ -152,12 +156,18 @@ Slot은 하나의 instance 안에 여럿 존재할 수 있다. 전부 하나의
## 마운트된 Slot의 재마운트는 즉시 throw (확정)
**사용자 확인 완료**: 이미 사용된(마운트된) slot을 재마운트하려 하면 **즉시
`error()`로 중단** — warn+no-op 아님. 개발 중 바로 잡아낼 수 있게 강하게
실패하는 쪽 선택. 마운트되는 순간 slot의 실제 대상은 고정된다 — 따라서
**사용자 확인 완료**: 이미 사용된(마운트된) slot을 **다른 위치로** 재마운트하려
하면 **즉시 `error()`로 중단** — warn+no-op 아님. 개발 중 바로 잡아낼 수 있게
강하게 실패하는 쪽 선택. 마운트되는 순간 slot의 실제 대상은 고정된다 — 따라서
**글로벌 스코프에서 slot을 쓰는 건 그다지 좋지 않을 수 있음**(재사용/재마운트가
막히므로).
> **[명확화, 2026-08-12 열두 번째 세션 메커니즘과 대조]** 위 throw는 **다른**
> `inst`로 마운트하려 할 때만 해당 — **같은** `inst`로 재-emit되는 경우(store
> 재발행 등으로 `process`가 같은 slot을 다시 받는 경우)는 예외적으로 no-op이지
> throw가 아니다. 상세 메커니즘은 아래 "Slot과 Store 바인드의 관계" 절의
> `SlotHandler.process`(`owner == inst` 분기) 참고.
## 클래스가 슬롯을 받는 방법
"네이밍된 슬롯"이 필요한가에 대한 사용자 자문: 그냥 슬롯 바인드 테이블을
@ -241,28 +251,54 @@ tables" 항목).** 즉 이 회피는 "혹시 몰라서"가 아니라 **Luau에
하나로만 두고, `kSlotMap`/`slotOwner`는 전부 `SetWeak`(순수 조회용,
아무것도 안 붙잡음)로 낮춤**:
**[일반화, 2026-08-12 열여섯 번째 세션] `slotOwner`는 top-level Dispatch
마운트만 보고 있어서, Slot-in-Slot으로 nested `Add`되는 경로(아래 "요소
소유권 — `elementOwner`" 절)와 서로 다른 레지스트리라 어느 한쪽이 이미
소유 중인 걸 다른 쪽이 못 보고 이중 마운트를 허용하는 gap이 있었음
(사용자 발견) — `slotOwner`를 element(범용, Slot이든 plain Instance든)
전체를 커버하는 `elementOwner`로 승격해 top-level Dispatch 경로와
nested CRUD 경로가 **같은** 레지스트리를 쓰도록 통합:
```lua
local kSlotMap = Relate() -- SlotHandler 전용, (inst,k)별 마지막으로 마운트한 Slot — weak, 조회 전용
local slotOwner = Relate() -- Slot 자신이 weak 키 — {[slot] = 지금 바인딩된 inst} — weak, 조회 전용
local kSlotMap = Relate() -- SlotHandler 전용, (inst,k)별 마지막으로 마운트한 Slot — weak, 조회 전용
local elementOwner = Relate() -- element(Slot이든 plain 마운트 가능 값이든) 전체 공용
-- {[element] = ownerKey} -- ownerKey: inst | Slot, 전부 weak
local OWNER = "__owner" -- sentinel key(Relate는 항상 3-인자 SetWeak/2-인자 GetWeak 이후
-- 값이라 outer key 하나당 값 하나만 저장하고 싶어도 key가 필요함 —
-- lifecycle-pattern.md의 GCCONN/GCHOLD와 같은 패턴, base/relate-plan.md 참고)
local function claimOwner(element, ownerKey)
local current = elementOwner:GetWeak(element, OWNER)
if current == ownerKey then
return false -- 이미 같은 owner — no-op, 재확인만
end
if current ~= nil then
error("이 요소는 이미 다른 곳에 마운트돼 있음 — 다중 마운트 금지")
end
elementOwner:SetWeak(element, OWNER, ownerKey)
return true
end
local function releaseOwner(element, ownerKey)
if elementOwner:GetWeak(element, OWNER) == ownerKey then
elementOwner:SetWeak(element, OWNER, nil)
end
end
function SlotHandler.process(inst, k, slotValue)
local owner = slotOwner:GetWeak(slotValue)
if owner == inst then
if not claimOwner(slotValue, inst) then
return -- 이미 이 inst에 바인딩된 채 — 단순 emit 전파, no-op
end
if owner ~= nil then
error("이 Slot은 이미 다른 곳에 마운트돼 있음 — 다중 마운트 금지")
end
attachSlot(slotValue, inst, inst, k) -- 내부에서 bindLifetime(inst, slotValue) 호출(아래)
slotOwner:SetWeak(slotValue, inst)
attachSlot(slotValue, inst, inst, k) -- top-level이라 내부에서 bindLifetime(inst, slotValue) 호출(위 "재귀 메커니즘" 절)
kSlotMap:SetWeak(inst, k, slotValue)
end
function SlotHandler.retract(inst, k, v)
local old = kSlotMap:GetWeak(inst, k)
if old and old ~= v then -- v는 nil일 수도, 대체하는 새 Slot 자체일 수도 있음
destroySlotTree(old) -- 내부에서 unbindLifetime(inst, old) 호출(아래), 폐기·옮기지 않음
slotOwner:SetWeak(old, nil)
destroySlotTree(old) -- 재귀 파괴(자식 정리), 폐기·옮기지 않음 — slot 자신의 GC 앵커는 안 건드림(top-level 전용)
unbindLifetime(inst, old) -- top-level 자신의 GC 앵커 해제 — attachSlot의 top-level bindLifetime과 짝
releaseOwner(old, inst)
kSlotMap:SetWeak(inst, k, nil)
end
-- old == v(같은 Slot 재발행) → 아무 것도 안 함, 곧 process도 owner==inst로 no-op
@ -270,10 +306,44 @@ end
```
`attachSlot` 자체가 quad-roblox 소속이라 `inst`를 아는 건 자연스러움 —
`slotOwner`가 굳이 `inst`의 정체를 몰라도(예: 다른 백엔드에서 중간
`elementOwner`가 굳이 `inst`의 정체를 몰라도(예: 다른 백엔드에서 중간
표현 테이블이어도) 무관하게 동작함, 그냥 "지금 이 자리를 차지한 값이
누구냐"만 구분하면 됨.
### 요소 소유권 — `elementOwner`, nested `Add`/top-level Dispatch 공용 (2026-08-12 열여섯 번째 세션)
**문제(사용자 발견)**: 위 `elementOwner`가 승격되기 전엔 top-level
Dispatch 마운트(`SlotHandler.process`)만 `slotOwner`를 봤고, `Add`
확인한다는 "개별 element 전역 weak-set"(구 "isMounted 이중 추적 분리"
절)은 완전히 별개 레지스트리로 서술만 있고 코드가 없었음 — 그래서
`slot1`을 top-level store-bind하면 `slotOwner`만 찍히고, 그 다음
`otherSlot:Add(slot1)`을 하면 `Add`가 보는 레지스트리엔 아무것도 없어
그대로 통과 → 같은 `slot1`이 두 군데 물리적으로 마운트되는데 에러가
안 남(반대 순서도 동일하게 뚫림) — "핵심 제약: 소유권 귀속과 단일
마운트" 절의 라이브러리 전역 불변식이 실제로는 안 지켜지던 gap.
**해법**: 위에서 승격한 `elementOwner`/`claimOwner`/`releaseOwner`를
Slot뿐 아니라 **모든 마운트 가능 element(plain Instance 포함)**의
소유권 판정에 공용으로 씀 — top-level(`SlotHandler.process`/`.retract`)과
nested(`rawAdd`/`rawRemove`/`rawExtract`)가 정확히 같은 함수, 같은
`Relate`를 호출하므로 어느 경로로 먼저 클레임하든 다른 경로가 반드시
봄:
```lua
-- rawAdd(self, element, index) 안, "이미 마운트" 에러 체크 자리
claimOwner(element, self) -- self = 담는 Slot. 이미 다른 곳 소유면 여기서 error
-- rawRemove(self, index)/rawExtract 안, 요소를 내보내는 자리
releaseOwner(element, self)
```
`ownerKey``inst`(top-level)든 `Slot`(nested)든 `elementOwner`
타입을 신경 안 써서 하나의 레지스트리로 충분 — `outerSlot`이 값으로
들어가도 `elementOwner` 자체는 아무것도 강하게 안 붙잡고(전부
`SetWeak`), 실제 강한 참조는 `outerSlot._elements`(plain array, `Relate`
아님)가 이미 쥐고 있으므로 `relate-plan.md`가 경고하는 "두 Relate
상호 강참조" 패턴과 다른 모양 — GC 문제 없음.
- **같은 바인딩이면 완전히 무시하는 게 이 자리에선 효율 문제가 아니라
정합성 문제** — Slot은 아래 "확정" 절대로 "폐기, 옮기지 않음"(portal
없음)이 이미 정책으로 확정돼 있어서, 이 no-op 가드가 없으면 **재귀 재
@ -859,7 +929,7 @@ function activateList(self, inst)
-- .Length만큼 건너뛴다 — 다음 형제의 index가 이 아이템이
-- 실제로 차지하는 물리적 개수를 반영해야 함(아래 "index도
-- nested-Slot 결과의 Length만큼 건너뛰어야 함" 절 참고)
pos = candidateIndex - 1 + (isSlot(result) and result.Length:Get() or 1)
pos = candidateIndex - 1 + (if isSlot(result) then result.Length:Get() else 1)
end
if result ~= prev then
@ -1090,9 +1160,9 @@ local function identityUpdateFn(item) return item end
function Slot:Single(state, updateFn)
updateFn = updateFn or identityUpdateFn -- [2026-08-11 일곱 번째 세션] 기본값 추가
local data = isState(state)
and state:Compute(function(v) return v:Get() == nil and {} or { v:Get() } end)
or (state == nil and {} or { state })
local data = if isState(state)
then state:Compute(function(v) return (if v:Get() == nil then {} else { v:Get() }) end)
else (if state == nil then {} else { state })
return self:List(data, function(item, index, offset, prev, ud)
return updateFn(item, offset, prev, ud) -- index는 항상 상수라 안 넘김
@ -1151,10 +1221,19 @@ weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위
local function attachSlot(slot, physicalTarget, ownerKey, position)
slot._mounted = true
slot._mountedInst = physicalTarget
bindLifetime(physicalTarget, slot) -- [2026-08-12 열세 번째 세션 추가] Slot 자신의
-- GC 앵커 — 이게 빠져있으면 아무도 slot을 강하게
-- 안 붙잡아 조기 GC될 수 있음(위 "Slot과 Store
-- 바인드의 관계" 절 GC 주의 참고)
if ownerKey == physicalTarget then
-- [2026-08-12 열여섯 번째 세션, 스코프 정정] bindLifetime은 최상위(물리
-- inst에 직접 연동하는 말단)에서만 — 중첩 Slot은 자신을 담는 outer의
-- `_elements`(plain strong array)로 이미 transitively 살아있어서
-- (elementOwner는 전부 weak라 별도 anchor 아님, 위 "요소 소유권 —
-- `elementOwner`" 절 참고) 여기서 또 anchor할 이유가 없음 — State의 노드 연결처럼
-- 중간 노드는 구조로만 연결되고, 말단만 실제 엔진 생명주기에 건다는
-- 원칙과 같은 결. 매 레벨 anchor하면 매 레벨 파괴 시 짝을 맞춰
-- unbindLifetime해야 하는 부담만 늘어남(destroySlotTree 참고).
bindLifetime(physicalTarget, slot) -- [2026-08-12 열세 번째 세션 추가] 최상위
-- Slot의 GC 앵커 — 이게 빠져있으면 아무도
-- 강하게 안 붙잡아 조기 GC될 수 있음
end
Dispatch.setLength(ownerKey, position, slot.Length) -- slot.Length는 State<number>, 기존 로직 그대로
local offsetSource = Source(0)
@ -1222,10 +1301,16 @@ local function destroySlotTree(slot)
unbindLifetime(slot._mountedInst, observer)
end
end
unbindLifetime(slot._mountedInst, slot) -- [2026-08-12 열세 번째 세션 추가]
-- attachSlot의 bindLifetime과 짝
-- [2026-08-12 열여섯 번째 세션, 스코프 정정] slot 자신의 unbindLifetime은
-- 여기서 안 부름 — attachSlot이 최상위에서만 bindLifetime하므로 짝도
-- 최상위 파괴 지점(SlotHandler.retract, 아래)에서만 한 번. destroySlotTree는
-- 재귀 전체에서 항상 이 위치까지만(자식 Observer 정리) 담당.
end
-- [명확화] 아래 시그니처는 index 기준 예시 — 위 "raw* 내부 호출 규약" 절이
-- 이미 못박았듯 reconcile(위 "여러 Slot이 섞일 때" 절 근처)은 element 기준으로
-- rawRemove(self, prev)를 부름. 둘 중 하나로 통일할지 얇은 변환 계층을 둘지는
-- 아직 M6 구현 세부로 열려 있음 — 이 블록은 그 결정 전 illustrative 예시.
function rawRemove(self, index)
local element = self._elements[index]
local bk = getBookkeeping(self)
@ -1349,7 +1434,7 @@ sugar가 성립하려면 `:Single(state)`(updateFn 생략)이 유효해야 함
(`:List`의 reconcile은 `key` 기준으로 독립 동작, `sub`가 바깥에서
어느 인덱스에 있든 상관 안 함).
- **`State<T?>`(nilable)도 특별 취급 없이 그냥 됨** — `:Single`이 이미
`v:Get() == nil and {} or {v:Get()}`로 nil을 "빈 리스트"로 흡수하므로, raw 직접
`if v:Get() == nil then {} else {v:Get()}`로 nil을 "빈 리스트"로 흡수하므로, raw 직접
전달 요소(`Add(element)`, State로 안 감싼 경우)에만 여전히 non-nil이
요구되고, `State`/`Source`로 감싼 값은 내부적으로 nilable이어도 아무
문제 없음 — 위 "요소 타입 제약" 절의 nil/None 금지는 **State/Source로
@ -1403,7 +1488,7 @@ Slot-in-Slot으로 `T = Instance | Slot<Instance>`가 허용되면서, `:List`
Slot(Length=3)을 반환할 때 다음 아이템의 `index`가 3만큼 안 건너뛰고
1만 건너뛰어 물리적으로 겹치는 LayoutOrder 범위가 나옴 — **의도된 동작으로
확정, 위 "구현" 절의 `reconcile` 의사코드에 이미 반영됨**(`pos = candidateIndex
- 1 + (isSlot(result) and result.Length:Get() or 1)`).
- 1 + (if isSlot(result) then result.Length:Get() else 1)`).
**남는 캐비엇 — `index`는 여전히 raw 스냅샷이라, nested Slot의 Length가
outer `:List`의 reconcile 없이 나중에 바뀌면 그 이후 형제들의 `index`

View file

@ -67,16 +67,20 @@ plain string이라(핸들러 계층 값처럼 identity/생명주기가 얽힌
"하나의 Tag 값을 프로그래밍적으로 조립"하는 용도).
**동적 토글은 `Source`/`State`로, `None` 불필요** — 상호배타 상태 전환은
`store.activeTag:Compute(function(name) return name:Get() == "btn1" and
Tag("selected") or nil end)`처럼 그냥 `nil`을 리턴하면 됨. `None` 센티널은
"정적 테이블 리터럴에서 `키 = nil`이 키 없음과 구별 안 되는" 문제의
해법이지(`bind-system-plan.md` "`None` 센티널" 절), 이건 함수 리턴값이
동적으로 흘러가는 경우라 그 문제 자체가 없음 — `nil`을 인자로 넘기는 건
아무 문제 없음. (단, `Frame { cond and Tag("a") or nil, sibling }`처럼
**정적 리터럴**에서 조건부로 Tag를 넣거나 빼고 싶은 경우엔 다른 array-part
값들과 마찬가지로 `cond and Tag("a") or None` 관용구가 여전히 유효 —
이건 nil-hole 문제라 Tag만의 특수 규칙이 아니라 `props.Modifier`/
`props.Ref`와 같은 일반 array-part 관용구.)
`store.activeTag:Compute(function(name) return (if name:Get() == "btn1"
then Tag("selected") else nil) end)`처럼 그냥 `nil`을 리턴하면 됨. `None`
센티널은 "정적 테이블 리터럴에서 `키 = nil`이 키 없음과 구별 안 되는"
문제의 해법이지(`bind-system-plan.md` "`None` 센티널" 절), 이건 함수
리턴값이 동적으로 흘러가는 경우라 그 문제 자체가 없음 — `nil`을 인자로
넘기는 건 아무 문제 없음. (단, `Frame { (if cond then Tag("a") else
nil), sibling }`처럼 **정적 리터럴**에서 조건부로 Tag를 넣거나 빼고
싶은 경우엔 다른 array-part 값들과 마찬가지로 `(if cond then Tag("a")
else None)` 관용구가 여전히 유효 — 이건 nil-hole 문제라 Tag만의 특수
규칙이 아니라 `props.Modifier`/`props.Ref`와 같은 일반 array-part
관용구. **[2026-08-12 세션 후속]** 예전엔 `cond and Tag("a") or
nil`/`or None`(and/or 삼항)으로 적었으나, `Tag(...)`가 항상-truthy라
당장은 안전해도 `if-then-else` 전면 금지 규칙(`base/architecture.md`
"코드 스타일" 절)에 예외를 안 두기로 하며 여기도 통일.)
## 메커니즘 — `TagHandler`, `retract`가 이제 의미 있어짐

View file

@ -1,6 +1,6 @@
# Tween / 애니메이션 플러깅 (전부 확정 — `base/`로 승격)
**상태**: research**2026-08-10 세션에서 구조 전체가 재설계됨.** 기존
**상태**: base**2026-08-10 세션에서 구조 전체가 재설계됨.** 기존
"`v`가 Store인 아무 `k`나 잡는 우선순위 최상위 Dispatch 핸들러" 모델은
`research/pre-implementation-audit.md` 1-1이 지적한 구조적 모호함("애니메이션
없는 일반 반응형 프로퍼티 바인딩도 결국 이름이 Tween인 파일을 거쳐가는가")을

View file

@ -112,8 +112,8 @@ v1에서도 `Corner`/`PaddingAll`/`Scale`은 store 값으로 바인드 가능했
"이전에 자기가 찾거나 만든 자식 Instance"를 얻어야 하는데, 이건 이미
base가 범용 유틸로 제공하기로 확정한 per-instance weak-keyed 저장소
(`Relate:SetStrong(inst,k,...)`, `base/relate-plan.md`/`base/bind-system-plan.md`
"핸들러 내부 상태 저장" 절)를 그대로 재사용하면 됨 — Tween 핸들러가 실행 중인 Tween
객체를 기억해두는 것과 정확히 같은 패턴. 새 메커니즘 발명 불필요, 이미
"핸들러 내부 상태 저장" 절)를 그대로 재사용하면 됨 — PropertyHandler가 실행 중인
Tween 상태를 기억해두는 것과 정확히 같은 패턴. 새 메커니즘 발명 불필요, 이미
있는 "store 바인드는 pluggable 바인드를 재실행하는 래핑" 원칙
(`base/bind-system-plan.md` "확정된 디스패치 모델" 절)이 그대로 적용됨.
@ -124,7 +124,7 @@ base가 범용 유틸로 제공하기로 확정한 per-instance weak-keyed 저
넣어도 충분하다, opt-out할 이유가 별로 없다"는 게 사용자 판단 — **작고
항상 켜져 있어도 비용이 무시할 만한 편의 기능은 별도 opt-out 패키지로
쪼개지 말고 `quad-roblox` 코어에 직접 포함한다**는 원칙으로 확정(이미
계획된 Tween 핸들러가 같은 모양이라는 게 근거). 이 원칙은 일반화해서
계획된 Tween 처리 로직이 같은 모양이라는 게 근거). 이 원칙은 일반화해서
재사용 가능 — 앞으로 비슷한 "작은 인스턴스 편의 기능"이 제안되면
`quad-roblox-util` 같은 걸 새로 만들지 않고 이 선례를 따르면 됨.

View file

@ -195,8 +195,14 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만**
있던 것을 이번에 `pre-implementation-audit.md` 2-11에도 해소 표시로
동기화. **[해소됨, 2026-08-09 세 번째 세션]** Slot CRUD 의미론
(`add`/`remove`/`clear`) 미정의(1-7)/`isMounted` 이중 추적 혼용(1-8) —
`base/slot-plan.md` 참고. **아직 실제로 열려있는 건 하나** — 우선순위
스캔 동률/매치실패 처리(1-3) — `pre-implementation-audit.md` 본문 참고.
`base/slot-plan.md` 참고. **[2026-08-12 감사 세션 정정] 아직 실제로
열려있는 건 하나가 아니라 넷** — `pre-implementation-audit.md` 원문에
`[해소됨]` 마커가 없는 항목이 1-3 외에도 셋 더 있음: 우선순위 스캔
동률/매치실패 처리(1-3), provider 미주입 상태 dispatch 처리(1-4),
`store.key`의 레코드 필드 타이핑을 M0로 앞당길지(1-10), Modifier
`__index`+`table.clone` 트릭 검증(1-11) — 이전 요약이 이 셋을 누락한
채로 "1-3 하나뿐"이라 잘못 축약해뒀던 것, `pre-implementation-audit.md`
본문 및 "다음 액션 제안" 절 참고.
- **[해소됨, 2026-08-08 두 번째 세션]** `Frame { ref }`/`Frame { observer }`처럼
children 배열 숫자 슬롯에 직접 놓는 leaf 값을 매칭·바인드하는 Handler
(`(i:number, v=Ref/Observer/PreRef)`)의 패키지 배치 — 원래 제안대로

View file

@ -209,6 +209,30 @@ introspection 로직을 추가하는 거라 라이브러리 복잡도가 늘어
큼. Slot을 거치지 않는 순수 `State<table> -> State<table>` 값 변환
용도라면 문제없이 들어갈 수 있음 — 실사용 사례가 나오면 재검토.
## 열린 질문 — Attribute 그룹 명시적 unset 유틸 (2026-08-12 세션 후속, 신설)
**배경**: `base/attribute-plan.md`가 "그룹에서 이름이 사라져도(diff든
통째 언마운트든) 프레임워크가 자동으로 `SetAttribute(name,nil)`
안 해준다 — 지울 거면 명시적으로 `None`을 써라"로 확정됨(사용자 결정,
Ref의 "Destroy 무관, 정리 필요하면 Effect" 철학과 통일). 이유는 diff로
조용히 빠지는 것과 통째 소멸을 다르게 취급하면 오히려 모호해지고,
Attribute는 이미 "겹치면 error"로 소유 코드가 명확히 갈리는 설계라
프레임워크가 대신 판단할 근거가 불투명하기 때문(상세는 해당 문서
"그룹 `Attribute(...)`" 절).
**아이디어(사용자 제시, 착수 안 함)**: 그래도 자동 unset이 갖고 싶으면,
`Animate`와 같은 모양의 `:Apply` 팩토리로 **명시적 opt-in 유틸**을 나중에
추가하면 됨 — `State<data> -> State<Attribute>`를 만들면서 내부적으로
이전 이름 집합과 비교해 사라진 이름을 자동으로 `None`으로 채워 넣어주는
콤비네이터. 이 카탈로그의 다른 항목들(`Sum`/`Concat`/`Sorted`)과 같은
성격 — 순수 슈가, 없어도 기능 격차 없음, 사용자가 명시적으로 골라야만
동작(자동/암묵적이 아님이 핵심 — 그래야 "프레임워크가 대신 판단"이라는
모호함 문제 자체가 안 생김).
**우선순위**: 이 문서 전체와 동급으로 맨 마지막, 실사용 사례가 나오면
재검토. 지금은 이름도 모양도 구체화 안 함 — 착수 시점에 이 문서의
`Animate`/`Sum` 패턴을 그대로 참고.
## 우선순위
**맨 마지막.** 없어도 quad는 기능상 완전하고, 함수 간 의존이 없어 나중에

View file

@ -0,0 +1,122 @@
# 2026-08-12 열여섯 번째 세션 — 코퍼스 전체 감사, Attribute retract 전면 재설계, Slot 소유권 일반화
## 배경
사용자가 "문서 전체에서 가정이 틀렸는데 다른 문서에서는 이미 정정된
사실이 있음에도 잘못된 추론이 그대로 남아있는 곳이 있는지" 감사를 요청.
이전 세션들(특히 2026-08-12 열한~열다섯 번째, retract-always-fires
정정과 그 파생 GC 이슈 시리즈)에서 큰 변경이 여러 차례 있었기 때문 —
"정정됨" 표시만 붙고 원 문장이 그대로 방치되는 패턴이 반복된다는 CLAUDE.md
자기 경고가 이번에도 실제로 맞아떨어짐.
## 1부 — 코퍼스 전체 감사 (7개 에이전트 병렬)
`base/` 14개 파일 + `research/`/`question.md`/`README.md`를 7개
서브에이전트로 나눠 정독·대조. 발견해서 그 자리에서 고친 것:
- **`bind-system-plan.md`**: "Tag는 retract가 무조건 전체 삭제"라던
옛 서술 2곳이 세션11 정정(참조 카운트 기반) 이후에도 안 고쳐진 채
남아있었음 — 수정.
- **`attribute-plan.md`**: "retract 불필요" 헤더가 바로 아래 정정된 코드
주석과 모순 — 수정(이후 2부에서 이 절 전체가 다시 크게 재작성됨).
- **`slot-plan.md`**: `slotOwner:GetWeak/SetWeak` 호출이 `Relate` 공식
API(항상 3-인자)와 인자 개수가 안 맞는 실제 버그 — sentinel key 추가로
수정. "재마운트는 즉시 throw" 절이 나중에 확정된 "같은 위치 재-emit은
no-op" 예외를 반영 안 함 — 교차참조 추가. `rawRemove`가 element
기준/index 기준 두 가지로 같은 파일에 리터럴로 존재 — M6 미확정임을
명시하는 주석 추가(결정 자체는 안 내림).
- **Tween 승격(research→base) 반영 누락**: `tween-plan.md` 상태 필드가
여전히 "research", `architecture.md` 2곳("Tween은 여전히 research/에
있음"), `ui-shorthand-plan.md`의 폐기된 "Tween 핸들러" 용어 — 전부 수정.
- **`question.md`/`CLAUDE.md`의 "지금 할 일" 1번**: `pre-implementation-audit.md`
우선순위1 중 열려있는 게 "1개(1-3)뿐"이라던 요약이 실제로는 4개(1-3/
1-4/1-10/1-11, 원문에 `[해소됨]` 마커 없음)였음 — 개수/목록만 정정,
실제 해소는 사용자가 "나중으로 연기" 선택.
## 2부 — Attribute retract 전면 재설계 (사용자 주도 다단계 정정)
diff를 직접 보던 사용자가 "AttributeHandler가 retract 없는 게 말이 되나"
질문에서 시작해 여러 라운드에 걸쳐 최종 설계에 도달:
1. **1차**: 그룹 자신의 `(inst,index)` 슬롯도 process/retract 코드가
없다는 gap 발견 — `AttributeGroupHandler.process`/`.retract` 스케치.
2. **2차 (사용자 catch)**: `retract``v` 타입을 안 보고 `v:NameMap()`
호출하는 게 버그 — `TagHandler.retract``isTag(newv)` 가드 선례와
대조해 확인. `Ref`/`Slot`/`AttributeKey`의 기존 retract는 전부
identity/nil 비교뿐이라 애초에 이 문제가 없었음(내용을 봐야 하는 건
Tag/Attribute뿐).
3. **3차 (사용자 catch)**: `AttributeKeyHandler.retract``v==nil`이면
`SetAttribute(name,nil)`을 직접 부르는 게 깜빡임(`a→nil→b`) 위험 —
"일반 프로퍼티와 동일하게 취급"이라는 기존 원칙대로 retract를 완전
no-op으로, 지우는 건 오직 `process(inst,k,nil)`(None/nil 명시)로만
통일.
4. **4차 (사용자 결정, 가장 큰 정정)**: 그룹이 "사라진 이름"을 자동으로
`SetAttribute(nil)` 해주는 것 자체를 기각 — **Attribute는 오직
명시적 `None`/`nil`로만 지워진다.** 그룹 바인딩이 통째로 사라져도
(컴포넌트 언마운트 등) 자동 청소 없음. 이유: diff로 조용히 빠지는
이름과 통째 소멸을 다르게 취급하면 오히려 모호해지고, Attribute는
이미 "겹치면 error"로 소유 코드가 명확히 갈리는 설계라 프레임워크가
대신 판단할 근거가 불투명함. `Ref`의 "Destroy 무관, 정리는 Effect로"
철학과 통일. 이 결정으로 기존 "Tag와 동일하게 확실히 청소" 초안(line
308-313)이 뒤집힘.
5. **5차**: (4차와 별개 문제) 값을 안 지워도 사라진 이름의 *구독*은
끊어야 함 — 안 그러면 그룹이 더 이상 안 쓰는 Source를 향한 StoreBind
구독이 인스턴스가 살아있는 동안 계속 살아남아 리소스 누수. `retract`
`Dispatch.retractUnder`만 부르고(`process`는 안 부름 — retract 안에서
process 호출은 UB, 아래 참고) 구독만 끊는 걸로 확정 — `SetAttribute`
여기서도 절대 안 일어남.
**부수적으로 확정된 일반 규칙 2개**(`bind-system-plan.md`에 추가):
- retract의 `v`는 타입 보장 안 됨 — 내용을 보려면 `isX(v)` 가드 필수.
- retract 안에서 `Dispatch.process` 호출은 UB(`retractUnder`의 체인
추적이 꼬임) — retract는 구조적 팝/자원 해제만, 새 등록 트리거 금지.
`Dispatch.retractUnder`를 (다른 키에 대해) retract 안에서 부르는 건
문제없음.
**백로그**: 그래도 자동 unset이 필요해지면 `Animate`와 같은 모양의
`:Apply` opt-in 유틸(이전 이름 집합과 비교해 사라진 이름을 `None`으로
채워주는 콤비네이터)을 나중에 추가 가능 — `research/operator-sugar-plan.md`
"Attribute 그룹 명시적 unset 유틸" 절로 신설, 착수 안 함.
## 3부 — Slot 소유권 일반화, bindLifetime 스코프 정정
같은 흐름에서 Slot도 감사:
- **소유권 레지스트리 이중화 gap(사용자 발견)**: top-level Dispatch
마운트(`slotOwner`)와 nested `Add`(문서에만 있고 코드 없던 "전역
weak-set")가 서로 다른 레지스트리라, 한쪽으로 먼저 마운트한 걸 다른
쪽이 못 보고 이중 마운트를 허용하는 실제 gap이 있었음. `slotOwner`
`elementOwner`(Slot이든 plain Instance든 공용)로 승격, `claimOwner`/
`releaseOwner`를 top-level(`SlotHandler`)과 nested(`rawAdd`/`rawRemove`/
`rawExtract`)가 동일하게 호출하도록 통합.
- **bindLifetime 스코프 정정(사용자 판단)**: `attachSlot`이 재귀 모든
레벨에서 `bindLifetime`을 부르던 것을 **top-level에서만**으로 축소 —
nested Slot은 담는 outer의 `_elements`(plain strong array)로 이미
transitively 살아있어서 별도 anchor가 불필요, State의 노드 연결처럼
말단만 실제 엔진 생명주기에 건다는 원칙과 일치. `destroySlotTree`
자기-unbind도 대칭으로 제거, top-level 파괴 지점(`SlotHandler.retract`)
에서만 짝 맞춤.
- **GC 안전성 확인**: `slot._mountedInst = physicalTarget`
`relate-plan.md`가 이미 안전하다고 확정한 "값이 자기 키를 다시
참조하는" 단일-Relate 자기참조 패턴(`Ref.Value=inst`와 동일 부류)이라
안전 — `relate-plan.md`에 확인 사례로 추가.
## 4부 — `and`/`or` 삼항 관용구 전면 금지
사용자가 성능(진짜 short-circuit이라 매 단계 truthiness 테스트, `if-then-else`
단일 분기)과 안전성(가운데 값 falsy 시 새는 구조적 결함) 둘 다 근거로
"쓸 수 있는 곳은 전부 `if-then-else`"를 요청 — 기존에 "가운데 값이
항상-truthy면 예외적으로 and/or 허용"이라던 예외 조항을 폐기하고, 코드로
실존하던 6곳(`bind-system-plan.md`/`slot-plan.md`/`tag-plan.md`)을 전부
`if-then-else`로 교체. `architecture.md`/`bind-system-plan.md`의 규칙
설명도 같이 강화. 단순 2항 `A or B` fallback(`props.Modifier or None`류)은
애초에 이 문제가 없어 그대로 유지.
## 반영 완료 확인
`base/attribute-plan.md`(221줄 변경), `base/slot-plan.md`(147줄),
`base/bind-system-plan.md`(67줄), `base/architecture.md`(22줄),
`base/tag-plan.md`(24줄), `base/relate-plan.md`, `base/tween-plan.md`,
`base/ui-shorthand-plan.md`, `research/operator-sugar-plan.md`,
`.claude/question.md`, 루트 `CLAUDE.md` 전부 이 세션 안에서 갱신 완료.
열린 설계 질문 없음 — Attribute/Slot 소유권 메커니즘 둘 다 최종 확정.

View file

@ -119,8 +119,12 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
없으면 그대로 M0 실제 코드 작성에 재사용.
- **`research/pre-implementation-audit.md` 우선순위1** — 신설 당시
11개였으나 대부분 이후 세션에서 해소됨(Tween/Tag 재설계, Slot CRUD,
`canExecute` 시그니처, `LifetimeHandle` 로드맵 순서 등). 실제로 아직
열려있는 건 "우선순위 스캔 동률/매치실패 처리"(1-3) 하나뿐 —
`canExecute` 시그니처, `LifetimeHandle` 로드맵 순서 등). **[2026-08-12
감사 세션 정정]** 실제로 아직 열려있는 건 넷(원문에 `[해소됨]` 마커가
없는 항목 기준) — 우선순위 스캔 동률/매치실패 처리(1-3), provider
미주입 상태 dispatch 처리(1-4), `store.key` 레코드 필드 타이핑을 M0로
앞당길지(1-10), Modifier `__index`+`table.clone` 트릭 검증(1-11).
이전엔 "1-3 하나뿐"이라 잘못 축약돼 있었음(1-4/1-10/1-11 누락) —
`.claude/question.md` 2번이 최신 상태.
2. **용어 정리 — 1차 제안 이후 대부분 확정, 소수만 남음.** 최신 소스는
`.claude/question.md` 1번(개수 반복 안 함, 항목 추가/해소될 때마다 여기가
@ -645,3 +649,22 @@ vararg 유지(요소 개수가 대개 소수로 고정, 동적이면 Slot-in-Slo
가능, `T|{T}``Slot<T>`가 base 레벨에선 `T`가 뭔지 모르는 제네릭이라
바깥 `{}`가 단일 T인지 배열인지 원천적으로 판별 불가능해서 오히려
모호해짐 — `Slot`의 T에 우연히 Slot이 섞여서가 아님, `slot-plan.md`).
**2026-08-12 열여섯 번째 세션 — 코퍼스 전체 감사, Attribute retract 전면
재설계, Slot 소유권 일반화** (`session/2026-08-12-16-corpus-audit-attribute-retract-slot-owner.md`)
7개 에이전트로 `.claude/` 코퍼스 전체를 감사해 stale 서술 다수 정정
(retract-always-fires 정정 전파 누락, Tween research→base 승격 반영
누락, `Relate` API 인자 개수 버그, `pre-implementation-audit.md` 열린
항목 개수 오류 등). 이어서 사용자가 diff를 직접 검토하며 Attribute
`retract`를 다단계로 재설계 — **최종: retract는 완전 no-op(SetAttribute는
오직 `process(inst,k,nil)`), Attribute는 오직 명시적 `None`/`nil`로만
지워짐(그룹이 사라진 이름을 자동으로 안 지워줌 — Ref의 "Destroy 무관"
철학과 통일), 단 사라진 이름의 *구독*은 끊음(값은 안 지워도 리소스는
안 새게)**. 일반 규칙 2개 신설(retract의 `v` 타입 미보장 → `isX(v)`
가드 필수, retract 안에서 `process` 호출은 UB). Slot도 `slotOwner`
`elementOwner`로 일반화해 top-level/nested 이중 마운트 gap 폐쇄,
`bindLifetime`을 top-level 전용으로 축소(nested는 `_elements` 강참조로
transitively 생존). `and`/`or` 삼항 관용구 전면 금지(기존 "항상-truthy면
예외" 조항 폐기), 코퍼스 전체 실제 코드 6곳을 `if-then-else`로 교체.
Attribute 자동 unset이 필요해지면 쓸 `:Apply` opt-in 유틸을
`research/operator-sugar-plan.md`에 백로그로 추가(착수 안 함).