decide(bind-system): retract fires on every re-dispatch, not just type changes

Corrects a foundational error: "handler type unchanged -> retract skipped,
process diffs" was never actually true. StoreBind unconditionally calls
Dispatch.retractUnder before every re-dispatch (already stated in the
original dispatch model doc) -- Tag's old assert(v==nil) was taken at
face value and the general rule was wrongly back-derived from it.

Tag, Ref, Slot, and Attribute (the last three designed earlier this same
session) all inherited the flawed premise. Fixes across all four:

- Tag: rewritten around kTagMap/tagNameMap reference counting so
  AddTag is entirely process's job, RemoveTag entirely retract's,
  using a Contains hint to skip engine calls that would be immediately
  redone. Also fixes the case where two independent Tag(...) values at
  different positions share a name (union semantics, like className).
- Ref/Slot: drop the incorrect assert(v==nil), split into "retract
  unbinds/destroys the old, process binds/mounts the new" with an
  identity check on both sides to avoid spurious re-fire.
- Attribute: AttributeKeyHandler.retract gated on v==nil so ordinary
  value updates don't flicker the attribute to nil; the group's
  "unchanged name" delegation now also retracts before reprocessing,
  since skipping it was silently stacking chain entries every cycle.

Reversal preserved in archive/retract-always-fires-reversed.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
qwreey 2026-08-12 16:13:25 +09:00
parent 6b7af54e0d
commit f20922ce39
Signed by: qwreey
GPG key ID: D28DB79297A214BD
10 changed files with 440 additions and 153 deletions

View file

@ -30,7 +30,7 @@
| `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서) |
| `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식 |
| `store-semantics.md` | Store는 부작용 허용이 기본. State는 Store 위의 조합 가능한 캐시 레이어로 실제로 필요함(2026-08-04 정정) — 온톨로지 핵심 메커니즘은 2026-08-04 2차 라운드에서 확정, 최신 상세는 `base/bind-system-plan.md` |
| `bind-system-plan.md` | pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인) |
| `bind-system-plan.md` | pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인). **[2026-08-12 열한 번째 세션, 전면 정정]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff"는 틀렸음 — `retract`는 store 재발행마다(핸들러 타입 무관) 항상 불림, `v`는 대체 값 자체일 수 있어 `nil`로 가정 금지. `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류로 설계돼 있었음이 드러나 한 세션에 전부 정정(`archive/retract-always-fires-reversed.md`) |
| `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정 |
| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot<T>()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn<UD>(item, index: number, offset: Source<number>, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot<Instance>`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/bind-system-plan.md``recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State<T>`/`Source<T>`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)``updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List``reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정 |
| `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정) |
@ -39,8 +39,8 @@
| `blocker-plan.md` | **[2026-08-07 신설]** `Blocker` — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게, State 마일스톤(M3)과 함께 개발. 메커니즘+이름 확정 |
| `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, state?)``state` 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React `useEffect` 동형). Observer와의 관계 해소 완료 |
| `ui-shorthand-plan.md` | **[2026-08-07 `research/`에서 승격]** `UICorner`/`UIPadding`/`UIScale` 인라인 편의 키 — 이름(v1 `Corner`/`PaddingAll`/`Scale`에서 Modifier 필드명과 안 겹치게 `UI` 프리픽스로 확정)·메커니즘(Handler)·패키지 배치(quad-roblox 코어)·store-bind 가능성까지 전부 확정. 이미지 라운드 트릭(`RoundSize`)은 드롭 — `archive/ui-shorthand-roundsize-dropped.md` 참고. `v=nil`이면 `process` 자신이 만든 자식 제거(`retract` 아님) |
| `tag-plan.md` | **[2026-08-08 세 번째 세션 재설계]** `Tag(...)` — array-part 값 객체, `Modifier`와 같은 immutable clone 체이닝(`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), `CollectionService` 글루만 quad-roblox. 이제 `retract`가 의미 있음(타입이 바뀌면 전체 삭제, 같은 Tag끼리는 `process`가 diff). 구 해시 파트 boolean 모델은 `archive/tag-hash-key-model-reversed.md` |
| `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey<T> "Name"]`(구 `Attribute<T>`) — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐, `retract` 불필요. **[2026-08-11 아홉 번째 세션]** 여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브 신설(`Tag`와 동형 array-part 값 객체, `Merged`로 헤테로지니어스 Store 합성), 이름 충돌 방지로 단일 키를 `AttributeKey`로 리네임(잠정). **[같은 세션 후속]** `AttributeKey(name)`이 이름별 weak 캐시로 동등성 보장하도록 확정되며, 그룹 Handler는 자기 완결형 재구현 대신 메모이즈된 키로 기존 단일 키 경로에 재귀 위임하는 걸로 개정(중복 구현 제거) |
| `tag-plan.md` | **[2026-08-08 세 번째 세션 재설계, 2026-08-12 열한 번째 세션 메커니즘 정정]** `Tag(...)` — array-part 값 객체, `Modifier`와 같은 immutable clone 체이닝(`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), `CollectionService` 글루만 quad-roblox. `retract`가 이전 Tag가 걸었던 이름을 이름별 참조 카운트 맵에서 빼고(다른 위치가 겹쳐 쓰면 실제 `RemoveTag`는 skip), `process`가 새 Tag의 이름을 등록 — 여러 위치가 같은 이름을 겹쳐 가져도(웹 `className`류 합집합) 안전. 구 해시 파트 boolean 모델은 `archive/tag-hash-key-model-reversed.md`, 구 `assert(v==nil)` 메커니즘은 `archive/retract-always-fires-reversed.md` |
| `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey<T> "Name"]`(구 `Attribute<T>`) — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐. **[2026-08-11 아홉 번째 세션]** 여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브 신설(`Tag`와 동형 array-part 값 객체, `Merged`로 헤테로지니어스 Store 합성), 이름 충돌 방지로 단일 키를 `AttributeKey`로 리네임(잠정). **[같은 세션 후속]** `AttributeKey(name)`이 이름별 weak 캐시로 동등성 보장하도록 확정되며, 그룹 Handler는 자기 완결형 재구현 대신 메모이즈된 키로 기존 단일 키 경로에 재귀 위임하는 걸로 개정(중복 구현 제거). **[2026-08-12 열 번째 세션]** 그룹/직접 쓰기가 같은 이름을 동시에 관리하는 충돌을 막기 위해 그룹은 공개 캐시 대신 `rawNew(name)` 전용 키+소유권 `Relate`로 전환. **[열한 번째 세션]** `retract`가 store 재발행마다 항상 불린다는 정정에 맞춰 `AttributeKeyHandler.retract``v==nil` 가드 추가(더 이상 "retract 불필요" 아님, no-op일 뿐), 그룹의 "남아있는 이름" 위임도 매번 `retractUnder`를 먼저 부르도록 정정(체인 누수 방지) |
| `onchange-plan.md` | **[2026-08-10 세션 신설]** `OnChange(name)``GetPropertyChangedSignal` 바인딩 전용 DI 키, `Attribute`와 달리 제네릭 타입 파라미터 없음(콜백 타입은 인라인 명시, 이벤트 바인딩과 같은 급 트레이드오프). 전부 quad-roblox(`Handlers/OnChange.luau`), `State<function>`은 기존 이벤트 store-bind 메커니즘 재사용. **[2026-08-11 아홉 번째 세션 후속]** `AttributeKey`와 동일한 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장 |
| `relate-plan.md` | **[2026-08-08 신설]** `Relate``inst`를 weak 키로 하는 범용 릴레이션 프리미티브(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`, 비싱글톤 생성자). 구 `base.perInstanceState(inst)` placeholder를 대체·정식 승격, `lifecycle-pattern.md``bindLifetime`/`canExecute`가 그 위에 얹힘 |
| `tween-plan.md` | **[2026-08-12 세션, `research/`에서 승격]** 값-레벨 `Tween<T>` 래퍼(PropertyHandler가 소비, 구 특수 bind key 모델은 `archive/tween-special-bind-key-reversed.md`). 3-상태 릴레이션 슬롯(`{Tween,Value}\|true\|nil`), `T'=T\|Tween<T>` 타입 치환. 옵션 값 모양은 `Info: TweenInfo?` 우선+편의 필드 폴백, override는 `Tween.Cancel`(기본)/`Tween.Finish` 2값. `Animate(info)``Tween` opts를 `T\|State<T>`로 받아 `:Apply`로 꽂는 sugar. 자연완료 시 per-instance 북키핑은 정리 안 해도 됨으로 확정(목표값 도달 상태라 부작용 없음, Completed 이벤트 구독 장치는 오버엔지니어링으로 판단). `initValue`는 사용자가 직접 처리(에이전트 범위 제외) |
@ -85,6 +85,7 @@
| `debug-channel-replicatedstorage-rejected.md` | **[기각됨, 2026-08-09 코퍼스 정리 신설]** quad-debug 채널을 `ReplicatedStorage`에 자동 생성하던 초안 — 게임 트리 오염 부작용으로 기각, quad 모듈 자신의 트리+`CollectionService` 태그로 대체 |
| `tween-special-bind-key-reversed.md` | **[역전됨, 2026-08-10 신설]** 구 Tween 모델(`[Tween(key,tweenData...)] = storeValue` 특수 bind key, 우선순위 최상위 Dispatch 핸들러) — 값-레벨 `Tween<T>` 래퍼 모델로 완전히 대체됨(`base/tween-plan.md`) |
| `onchange-per-property-codegen-rejected.md` | **[기각됨, 2026-08-10 신설]** `OnChange.PropertyName` 프로퍼티별 정적 코드 생성 — Attribute의 정적 지름길과 달리 (클래스 수 × 프로퍼티 수) 규모로 폭발해 기각, `OnChange(name)` 단일 팩토리로 대체 |
| `retract-always-fires-reversed.md` | **[역전됨, 2026-08-12 열한 번째 세션 신설]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff" — 실제로는 `retract`가 store 재발행마다 항상 불림(핸들러 타입 무관). `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류 위에서 설계돼 있었음이 드러나 한 세션에 전부 정정 |
## 참고

View file

@ -0,0 +1,79 @@
# [역전됨] "핸들러 타입이 안 바뀌면 retract 없이 process가 diff" — `retract`는 store 재발행마다 항상 불림으로 정정
**역전 일시**: 2026-08-12 (열한 번째 세션). **원 확정 일시**: 2026-08-07
(여덟 번째 세션, `bind-system-plan.md`의 일반 retract 계약 절)~2026-08-09
(Tag의 `assert(v==nil)` 명시화).
**현재 유효한 설계**: `base/bind-system-plan.md`의 일반 retract 계약
절(`retract(inst,k,v)` 항목), `base/tag-plan.md`/`base/attribute-plan.md`
"이름 소유권"/"메커니즘" 절, `base/bind-system-plan.md`의 "`Ref`의 retract"
절, `base/slot-plan.md` "Slot과 Store 바인드의 관계" 절이 최종 소스.
## 역전된 사례 — 원래 무엇을 확정했었나
**"retract가 실제로 의미 있는 유일한 패턴은 같은 키에 대해 매치되는
핸들러 *타입 자체*가 사이클마다 바뀌는 경우"** (2026-08-07 여덟 번째
세션 정정 당시 확정, `bind-system-plan.md`) — 예로 `Tag(...)`↔`nil`을
들며, "같은 Tag끼리 바뀌는 diff는 process가 담당"(즉 `Tag(A)→Tag(B)`
같은 동일 핸들러 타입 전환에서는 `retract`가 아예 안 불린다)고 명시.
`base/tag-plan.md`는 이 전제를 코드에 그대로 반영해 `TagHandler.retract`
`assert(v == nil, "TagHandler.retract는 v가 nil일 때만 불려야 함")`
넣었고(2026-08-09 열한 번째 세션 "명시화"), 이후 2026-08-12 여덟/아홉
번째 세션에 `Ref`/`Slot`도 같은 전제("핸들러 타입이 안 바뀌면 retract
없이 process가 diff")를 그대로 이어받아 각자 `assert(v==nil)`을 두고
`process` 안에서 old-vs-new diff를 계산하는 설계로 확정됐었음.
## 역전된 이유
`Attribute`의 이름 소유권 설계를 논의하다 사용자가 지적: `Tag`도 서로
다른 배열 위치의 `Tag(...)`가 같은 이름을 겹쳐 가질 수 있는데
(`Frame { Tag("a"), Tag("a","b") }`, 웹 `className`과 같은 합집합
시맨틱), 한 위치의 diff만으로 다른 위치가 아직 쓰는 이름을 지워버리는
참조 카운트 버그가 날 수 있다는 문제 제기에서 시작. 이를 풀기 위해
retract 쪽에서 "새로 들어올 값이 이 이름을 여전히 필요로 하는가"를
확인하는 설계(`Tag:Contains()` 힌트)를 사용자가 제안했는데, 이게
성립하려면 **`retract``v=Tag`(nil이 아닌 대체 값)를 받는 경우가
실제로 있어야 함** — 기존 `assert(v==nil)`과 정면으로 모순.
`bind-system-plan.md`의 "확정된 디스패치 모델" 절(2026-08-04 원문)을
다시 대조하니, `Dispatch/StoreBind.luau`는 재-dispatch 전에 **무조건**
`Dispatch.retractUnder(inst,k,self,realv)`를 부른다고 이미 명시돼
있었음 — "핸들러 타입이 안 바뀌면 생략"이라는 조건은 그 문서 어디에도
없었음. 즉 2026-08-07 세션의 "retract가 의미 있는 유일한 패턴" 서술이
자기 문서의 다른 절과 처음부터 모순돼 있었고, `Tag``assert(v==nil)`
액면 그대로 믿고 거꾸로 일반 규칙을 잘못 추론한 게 오류의 실제 출처
(2026-08-09 "명시화" 세션에서도 이 모순이 안 걸림). 사용자가 직접
"어떤 값이든 덮여 쓰여지는 즉시 retract를 실행하는 거로 두기로 했었다 —
전체 process 트랙을 retract하고 리빌드한다는 맥락"이라고 확인하며
확정.
## 정정된 이해
- `retract(inst,k,v)`는 store 바인드가 재발행될 때마다(핸들러 타입이
안 바뀌어도) 항상 불림. `v``nil`일 수도, 그 자리를 대체하는 새
값 자체일 수도 있음 — **`retract` 안에서 `v`를 절대 `nil`
가정하면 안 됨.**
- 대부분의 핸들러(일반 PropertyHandler, `NoneHandler`, UICorner
숏핸드 등)는 이 반복 호출에서 실제로 할 일이 없어 `retract`
사실상 no-op — "타입이 안 바뀌면 아예 안 불린다"가 아니라 "불리지만
몸체가 비어 있어도 된다"가 정확한 표현.
- `Tag`/`Ref`/`Slot`/`Attribute`처럼 여러 위치가 하나의 실제 리소스를
공유하거나 값 자체가 정리가 필요한 상태를 들고 있는 핸들러는, `v`
힌트 삼아 "곧 다시 필요해질 것"이면 실제 엔진 호출만 skip하는
방식으로 대응 — `retract`가 이전 기여 제거(힌트로 skip 가능),
`process`가 새 기여 등록을 전담하는 분업이 자연히 나옴, `process`
쪽에 별도 old-vs-new diff가 더 이상 필요 없어짐.
## 영향받은 문서 (이 세션에 모두 정정 완료)
- `base/bind-system-plan.md` — 일반 retract 계약 절, `NoneHandler` 절,
"`Ref`의 retract" 절.
- `base/tag-plan.md``TagHandler` 메커니즘 전면 재작성(`kTagMap`/
`tagNameMap` 참조 카운트).
- `base/slot-plan.md` — "Slot과 Store 바인드의 관계" 절, `destroySlotTree`
호출이 `process`에서 `retract`로 이동.
- `base/attribute-plan.md``AttributeKeyHandler.retract``v==nil`
가드 추가, 그룹의 이름 집합 diff가 "남아있는 이름도 먼저
`retractUnder`" 방식으로 정정(체인 누수 방지).
- `base/ui-shorthand-plan.md`, `base/tween-plan.md` — 결론(해당 핸들러의
`retract`는 no-op)은 안 바뀌었으나, "retract가 아예 안 불린다"는
근거 서술만 정정.

View file

@ -164,9 +164,15 @@ function AttributeKeyHandler.process(inst, k, v)
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 then
if map and map[name] == k and v == nil then
map[name] = nil
inst:SetAttribute(name, nil)
end
@ -183,17 +189,21 @@ end
만들어 이 맵에 캐싱하고 그 키로 위임, 이미 맵에 있으면(이전 사이클에
이미 관리 중이던, 즉 "남아있는" 이름) **그 캐싱된 같은 객체를 그대로
재사용**해서 위임.
- **왜 이러면 "새 Attribute 셋 비교"가 저절로 맞아떨어지는지(사용자 확인,
2026-08-12 열 번째 세션)**: 그룹이 이름 집합을 diff할 때 — **남아있는
이름은 캐싱된 같은 키 객체로 재위임하므로 `owners` 맵에서 `current == k`
성립해 통과, `retract` 자체가 안 불림**(값만 갱신) — **사라진 이름만
`Dispatch.retractUnder`가 그 이름 전용 키를 타서 `retract`가 불리고
`nil`화됨.** 새로 들어온 이름은 `rawNew`로 갓 만든 키라 `owners`에 없어
그냥 새로 클레임. 즉 "진짜 새 셋과 비교해 사라진 것만 nil화, 나머지는
건드리지 않고 갱신"이 diff 로직을 하나도 안 고치고 위 `process`/`retract`
구현만으로 자연히 나옴 — **캐시가 그룹 값 교체를 넘어 계속 유지돼야만
성립**(매 교체마다 키를 새로 만들면 남아있는 이름도 `owners`엔 옛
객체가 남아있어 새 객체와 비교 시 오탐 충돌이 남).
- **[정정, 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하는 얇은 함수라 이 추가 호출의 비용은 무시할
수준.
- **패키지 경계**: `AttributeKey` 자체가 이미 quad-roblox 소속(Tag와
달리 base/roblox로 안 쪼갬, 아래 "패키지 배치" 절)이고 그룹의 실제
위임 로직도 이미 roblox 쪽 글루라 `rawNew` 호출이 새 역의존을 안 만듦 —
@ -254,14 +264,28 @@ Dispatch 재진입 없이 직접 `SetAttribute`+수동 per-field StoreBind 구
attribute 이름 → 그 이름 전용 키 객체 맵"**(구 "이름 문자열 집합" —
이름 존재 여부뿐 아니라 그때 쓴 키 객체 자체까지 같이 들고 있어야 위
"이름 소유권" 절의 동일 객체 재사용이 성립)과 새 값의 키 집합을 diff:
- 사라진 이름만, 그 이름이 맵에 들고 있던 **그 전용 키 객체**로
`Dispatch.retractUnder(inst, key)` — 그 이름이 살아있는 동안 만들어졌던
원래 체인과 정확히 같은 슬롯을 가리켜 정리됨. 맵에서도 그 이름 제거.
- 남아있는 이름은 **맵에 이미 캐싱된 같은 키 객체를 그대로 재사용**,
새로 들어온 이름은 `rawNew(name)`로 갓 만든 키를 맵에 새로 캐싱 —
**값 비교 없이 전부** `Dispatch.process(inst, key, source)`로 넘김,
작성자가 직접 `[AttributeKey<<T>> name] = source`를 쓴 것과 거의 같은
경로를 타므로(단, 공개 캐시가 아니라 그룹 전용 키를 씀) `source`
- **[정정, 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 계약("값은

View file

@ -115,43 +115,51 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
lifecycle-pattern.md`의 "quad는 라이프사이클 중간에 있지 않다" 원칙 참고).
- 일반 프로퍼티는 애초에 "unset" 개념이 없음(`nil`로 셋하는 것도 그냥 셋
동작) — 그래서 프로퍼티 핸들러는 보통 `retract`가 필요 없음.
- **`retract`가 실제로 의미 있는 유일한 패턴은 "같은 키에 대해 매치되는
핸들러 *타입 자체*가 사이클마다 바뀌는 경우"** (2026-08-07 여덟 번째
세션, 정정) — 예: `Tag(...)`↔`nil` 사이에서 핸들러 타입 자체가
바뀌므로 `retract`가 의미 있어짐(전체 삭제), 같은 Tag끼리 바뀌는
diff는 `process`가 담당(`base/tag-plan.md`, 2026-08-08 세 번째 세션
— array-part 값 객체 재설계 이후, 구 모델은 `archive/
tag-hash-key-model-reversed.md`). **Attribute(단일 키 직접 쓰기 경로)는
여기 해당 안 함** — UICorner 숏핸드와 같은 패턴(값의 참/거짓/nil
여부와 무관하게 항상 같은 핸들러가 계속 담당, 추가/제거를 전부
`process` 자신이 처리)이라 핸들러 교체 자체가 안 일어남 —
`base/attribute-plan.md`. **[추가, 2026-08-12 열 번째 세션] 단, 그룹
`Attribute(...)`가 개별 이름을 놓을 때는 그 이름 전용 키 객체의 체인
자체가 통째로 정리되는 거라 `retract`가 실제로 불림** — "핸들러
타입이 안 바뀌면 retract 없이 process"라는 이 절의 원칙과 안 어긋남,
그룹이 이름을 잃는 건 "그 이름 전용 키가 이 인스턴스를 더 이상
관리 안 하게 됨"이라 오히려 `Tag(...)→nil`과 같은 급의 "완전히
사라짐" 케이스임 — `attribute-plan.md` "이름 소유권" 절 참고.
**[정정,
2026-08-10 세션] Tween도 더 이상 여기 해당하지 않음** — 원래는 이
패턴의 대표 예시("Tween 핸들러가 매치돼 애니메이션 실행 중이었는데
값이 더 이상 Tween 대상이 아니게 되어 일반 PropertyHandler로 매치가
넘어가는 경우")였으나, Tween이 독립 Dispatch 핸들러가 아니라
PropertyHandler가 소비하는 값-레벨 래퍼(`Tween<T>`)로 재설계되며
매치되는 핸들러가 항상 PropertyHandler 하나뿐이 되어 이 케이스
자체가 사라짐 — 트윈 취소/전환은 이제 PropertyHandler 내부의
3-상태 릴레이션 슬롯으로 처리(`base/tween-plan.md`, `archive/
tween-special-bind-key-reversed.md`). **[추가, 2026-08-12 여덟 번째
세션] `Ref``Tag`와 같은 결** — `State<Ref>``refA`에서 `refB`
바뀌는 건 둘 다 같은 Ref-leaf handler가 매치하므로 `retract`가 아니라
`process`의 diff가 담당(이전 Ref를 `:Set(nil)`로 언바인딩), `retract`
그 자리가 Ref이길 아예 그만둘 때만 — 아래 "`Ref`의 retract" 절 참고.
**[추가, 2026-08-12 아홉 번째 세션] `Slot`도 같은 패턴, 단 diff가 아니라
identity 비교** — `State<Slot>``slotA→slotB`로 바뀌는 것도 같은
SlotHandler가 매치하므로 `process`가 처리. `Tag`/`Ref`처럼 세밀한 diff
대신 "같으면 완전 무시, 다르면 이전 것 통째로 폐기 후 새로 마운트"(Slot은
portal 없이 폐기만 하기로 이미 확정돼 있어서) — `slot-plan.md` "Slot과
Store 바인드의 관계" 절 참고.
- **[전면 정정, 2026-08-12 열한 번째 세션] `retract`는 "핸들러 타입이
바뀔 때만" 불리는 게 아니라, **store 바인드가 재발행될 때마다(값이
뭐로 바뀌든) 항상 불림** — 위 "확정된 디스패치 모델" 절이 처음부터
말해온 그대로: `StoreBind`는 재-dispatch 전에 **무조건**
`Dispatch.retractUnder(inst,k,self,realv)`를 부르고, `retractUnder`
`keep` 바로 다음 항목에게 그 `realv`를(그 다음 항목들에겐 `nil`을)
넘기며 체인을 통째로 걷어낸 뒤에야 `Dispatch.process`가 다시 매치를
시도해 새 체인을 쌓음. 즉 **"핸들러가 안 바뀌면 retract 없이 process가
diff"라는 이전 서술은 틀렸음** — `Tag`의 옛
`assert(v == nil, "TagHandler.retract는 v가 nil일 때만 불려야 함")`
액면 그대로 믿고 거꾸로 일반 규칙을 추론한 게 오류의 출처(2026-08-07
여덟 번째 세션 정정 당시엔 안 걸렸던 부분, `bind-system-plan.md` 자기
"확정된 디스패치 모델" 절과 실제로 모순돼 있었음). 이 오류는
`base/tag-plan.md`(원 출처)와, 이번 대화에서 그걸 그대로 이어받은
`Ref`/`Slot`/`Attribute` 세 곳 전부에 퍼져 있었음 — 전부 정정
완료(각 문서의 해당 절 참고). **[아카이브, `archive/
retract-always-fires-reversed.md`]**.
- **정정된 원칙 — 대부분의 핸들러는 이 반복 호출에서 실제로 할 일이
없어(일반 프로퍼티처럼 값을 그냥 덮어쓰면 끝이라 "unset" 개념 자체가
없음) `retract`가 사실상 no-op일 뿐, "타입이 안 바뀌면 retract가 아예
안 불린다"는 뜻이 아님.** `Tag`/`Ref`/`Slot`/`Attribute`처럼 **여러
위치가 하나의 실제 리소스(엔진 attribute/tag/mounted 서브트리 등)를
공유하거나, 값 자체가 정리가 필요한 상태를 들고 있는** 핸들러는,
`retract`가 매번 불려도 **"이전 값이 지금 들어오는 새 값(`v`)과
사실상 같은지/그 새 값이 여전히 이 자원을 필요로 하는지"를 `v`
힌트 삼아 판단해 실제 엔진 호출만 skip**하는 방식으로 대응해야 함 —
`Tag``Contains` 힌트, `Ref`/`Slot`의 identity 비교가 그 예. **`v`
반드시 `nil`로 가정하면 절대 안 됨**(대체하는 새 값 그 자체일 수
있음) — 새 핸들러를 짤 때 `retract` 안에서 `v`의 타입을 방어적으로
확인할 것(2026-08-12 열한 번째 세션, 실제로 `Tag(...)`/`Ref`/`Slot`
설계 전부에서 이 확인이 빠져 있었음이 드러남).
- **자연스러운 분업**: 여러 위치가 자원을 공유하는 핸들러는 대개
"`retract`가 이전 기여를 걷어내고(실제 해제는 `v` 힌트로 skip
가능), `process`가 새 기여를 등록한다"는 모양으로 깔끔히 갈림 —
`process` 쪽에 별도 old-vs-new diff가 필요 없어짐(그 diff를 `retract`
이미 통째로, 매번 정확하게 해주므로). `Tag(...)`↔`nil`, `Attribute`
그룹이 이름을 놓는 경우도 이 분업의 자연스러운 특수 케이스일 뿐, 별도
패턴이 아님 — 상세 구현은 `base/tag-plan.md`/`base/attribute-plan.md`
"이름 소유권" 절, `Ref`는 아래 "`Ref`의 retract" 절, `Slot`
`slot-plan.md` "Slot과 Store 바인드의 관계" 절 참고.
- Tween은 이 패턴과 무관 — 독립 Dispatch 핸들러가 아니라 PropertyHandler가
소비하는 값-레벨 래퍼(`Tween<T>`)라 매치되는 핸들러가 항상
PropertyHandler 하나뿐(2026-08-10 세션 재설계) — 트윈 취소/전환은
PropertyHandler 내부의 3-상태 릴레이션 슬롯으로 처리(`base/tween-plan.md`,
`archive/tween-special-bind-key-reversed.md`).
- store bind가 새 값으로 넘어갈 때 이전 핸들러의 `retract`를 호출해주면
됨 — **정확한 전파 메커니즘은 아래 "Dispatch 체인" 절 참고**(재귀
재-dispatch에서 여러 단계가 겹칠 때 어느 슬롯에 뭘 추적하는지가
@ -292,11 +300,13 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출
프로퍼티(Color3/number 등)에 도달하면 `inst[k] = nil`은 런타임 에러 —
PropertyHandler 자신이 `v == nil`이면 셋을 건너뛰는 방어를 갖고 있어야
함(None 자체의 문제가 아니라 PropertyHandler 구현 디테일, M9/M10로 미룸).
- **retract와는 무관**`retract`는 "같은 키를 다른 *핸들러 타입*이
넘겨받는" 시나리오 전용(아래 정정된 "확정된 디스패치 모델" 절)이지
"`v`가 `nil`이 됨"과는 다른 문제. `None → nil` 재디스패치는 항상
`Dispatch.process` 경로로만 흐름 — `NoneHandler` 자신도 `retract`
딱히 할 일이 없음(재귀 호출 자체가 이미 process이므로).
- **`retract`는 여기서 할 일이 없음** — **[정정, 2026-08-12 열한 번째
세션]** `retract`는 store 재발행마다 항상 불리지만(위 "확정된 디스패치
모델"/일반 retract 계약 절 정정분 참고), `NoneHandler``v==None`
매치했을 때 재귀 호출로 곧바로 `Dispatch.process(inst,k,nil)`
부르는 게 전부고 자기 자신이 들고 있는 별도 상태가 없어서(`Relate` 등
전혀 안 씀) `retract`가 불려도 정리할 게 없음 — 일반 프로퍼티 핸들러가
`retract`를 no-op으로 두는 것과 같은 이유.
- **[해소됨, 2026-08-08 세 번째 세션]** "이 키를 지금 누가 담당 중인가"
bookkeeping — `pre-implementation-audit.md` 우선순위1 "이전에 실제로
매치됐던 핸들러 추적" 항목이 여기서 다시 언급됐던 것. 아래 "Dispatch
@ -1005,11 +1015,13 @@ tween-plan.md`도 이에 맞춰 갱신됨). Ref의 진짜 용도는 다름:
`refB`로 넘어갔다는 걸 모르는 코드가 `refA.Value`를 계속 유효하다고 믿는
조용한 버그가 남음 — `PreRef` 재사용 버그(위 절)와 같은 클래스의 문제.
**메커니즘 — `TagHandler`(`base/tag-plan.md`)와 정확히 같은 패턴 재사용,
새 장치 아님.** `Dispatch`의 일반 규칙("핸들러 *타입*이 안 바뀌면 retract
없이 process만 다시" — `refA→refB`는 둘 다 같은 Ref-leaf handler가
매치하므로 여기 해당)을 그대로 따르면, `refA→refB` 전환은 `retract`
아니라 **`process` 자신이 이전 값을 기억해뒀다가 diff**해야 함:
**메커니즘 — `retract`가 매번 불린다는 전제 위에서 언바인딩 전담
(2026-08-12 열한 번째 세션 정정).** `Dispatch.retractUnder`는 store 값이
바뀔 때마다(핸들러 타입이 그대로여도) 무조건 불림 — 위 "확정된 디스패치
모델"/일반 retract 계약 절 참고. 그래서 `refA→refB` 전환도 `retract(inst,k,
refB)`가 먼저 불려 `refA`를 언바인딩하고, 그 다음 `process(inst,k,refB)`
`refB`를 바인딩하는 두 단계로 자연히 갈림 — `process`가 old-vs-new diff를
따로 계산할 필요가 없어짐(그 일을 `retract`가 매번 정확히 대신 해줌):
```lua
local relate = Relate() -- Ref-leaf handler 전용, (inst,k)별 마지막으로 바인딩한 Ref 기억
@ -1018,34 +1030,32 @@ RefLeafHandler.isHandlable(inst, k, v) = isRef(v) and not isPreRef(v)
function RefLeafHandler.process(inst, k, v)
local old = relate:GetStrong(inst, k)
if old and old ~= v then
old:Set(nil) -- 이전 Ref 언바인딩 — 매 :Set()마다 콜백 재통지되는
-- 기존 Ref 규칙(위 "해소됨 — 반복 재설정 가능" 항목)을
-- 그대로 재사용, 새 알림 경로 아님
end
if old == v then return end -- 이미 같은 Ref가 이 자리를 차지 중(retract가
-- 방금 손 안 댄 경우) — 콜백 재통지 없이 no-op
v:Set(inst)
relate:SetStrong(inst, k, v)
end
function RefLeafHandler.retract(inst, k, v)
assert(v == nil, "Ref 자리가 더 이상 Ref가 아니게 될 때만 불림 — TagHandler와 같은 이유")
local old = relate:GetStrong(inst, k)
if old then old:Set(nil) end
relate:SetStrong(inst, k, nil)
if old and old ~= v then -- v는 nil일 수도, 대체하는 새 Ref 자체일 수도 있음 —
-- 어느 쪽이든 old와 다르면 old는 확실히 이 자리를 잃음
old:Set(nil) -- 매 :Set()마다 콜백 재통지되는 기존 Ref 규칙(위 "해소됨 —
-- 반복 재설정 가능" 항목)을 그대로 재사용, 새 알림 경로 아님
relate:SetStrong(inst, k, nil)
end
-- old == v(같은 Ref 재발행) → 아무 것도 안 함, 곧 process도 no-op으로 스킵
end
```
- **`retract`는 이 자리가 Ref이길 아예 그만둘 때만 불림**(Store 값이 Ref가
아닌 다른 것으로 바뀌어 다른 Handler가 매치되는 경우) — `refA→refB`처럼
Ref끼리 바뀌는 흔한 경우는 위 `process`의 diff가 담당. `retract`
`process` 양쪽 다 결국 `old:Set(nil)` 하나로 귀결되므로 실질적으로
"언바인딩 로직은 하나, 트리거 경로만 둘"인 구조 — Tag의 diff/전체삭제
분리와 동형.
- **`retract`가 언바인딩 전담, `process`는 바인딩 전담** — 겹치는 diff
로직이 없음. `old == v`(같은 Ref 객체가 스스로 재발행된 spurious한
경우)만 둘 다 스킵해 콜백이 `nil`→`inst`로 헛되이 두 번 안 불리게 함.
- **children 배열 리터럴 `Ref`도 같은 코드 경로를 그대로 씀** — 그 경우
`relate:GetStrong(inst,k)`가 애초에 `nil`(그 자리에 처음 오는 값)이라
`old`가 없어 언바인딩 분기를 안 타고 바로 `v:Set(inst)`로 끝남. 즉
"1회성 리터럴 구성"과 "반복 재바인드"가 하나의 구현으로 자연히 커버됨,
케이스 분기 불필요.
`retract`가 (StoreBind 경로가 아니라 이 리터럴 구성 자체가 처음이므로)
아예 안 불리고 `relate:GetStrong(inst,k)``nil`이라 `process`가 바로
`v:Set(inst)`로 끝남. "1회성 리터럴 구성"과 "반복 재바인드"가 하나의
구현으로 자연히 커버됨, 케이스 분기 불필요.
- **타입: 비-nilable `T`도 정당한 용도(사용자 확인, 2026-08-12 여덟 번째
세션)** — `Ref`는 "채워지길 기다리는 박스"뿐 아니라 "이미 확정된 값을
여기저기서 부작용 없이 읽는" 용도로도 쓰일 수 있어 `Ref<T>`(T가

View file

@ -198,15 +198,15 @@ Slot이 store 바인드로 들어오는 경우, pluggable 처리기에 `retract`
> 동작이 '부모 위임' 잠정안에서 '폐기(옮기지 않음)'로 확정") 참고. 이 문단은
> 검토 과정의 히스토리로만 남겨둠, 현재 유효한 동작 아님.
**[정정, 2026-08-12 아홉 번째 세션] 위 "store 바인드 핸들러가 이전 slot을
`retract`하고 다시 `process`하는 사이클" 서술은 부정확했음 — `Ref`
retract를 검토하며 발견한 것과 같은 오류.** `base/bind-system-plan.md`
일반 계약("핸들러 *타입*이 안 바뀌면 `retract` 없이 `process`가 diff
담당", `Tag`/`Ref`가 실제 선례)을 그대로 적용하면, `State<Slot>`
`slotA→slotB`로 바뀌는 것도 둘 다 같은 SlotHandler가 매치하는 경우라
**`retract`가 아니라 `process` 자신이 처리해야 함** — `retract`는 그
자리가 아예 Slot이길 그만둘 때만. 메커니즘은 `Ref`(`bind-system-plan.md`
"`Ref`의 retract" 절)와 같은 모양의 `Relate` 기반 diff:
**[전면 정정, 2026-08-12 열한 번째 세션] 위 "핸들러 타입이 안 바뀌면
retract 없이 process가 diff 담당"이라는 전제 자체가 틀렸음** — 실제로는
`Dispatch.retractUnder`가 store 재발행마다(핸들러가 그대로여도) **항상**
먼저 불림(`base/bind-system-plan.md` "확정된 디스패치 모델"/일반 retract
계약 절, 2026-08-12 열한 번째 세션 전면 정정 참고). 그래서 `State<Slot>`
`slotA→slotB`로 바뀔 때도 **`retract(inst,k,slotB)`가 먼저 불려 `slotA`
폐기하고, 그 다음 `process(inst,k,slotB)``slotB`를 마운트**하는 두
단계로 자연히 갈림 — `retract`가 "이전 것 정리", `process`가 "새 것
마운트" 전담:
```lua
local relate = Relate() -- SlotHandler 전용, (inst,k)별 마지막으로 마운트한 Slot 기억
@ -214,33 +214,33 @@ local relate = Relate() -- SlotHandler 전용, (inst,k)별 마지막으로 마
function SlotHandler.process(inst, k, slotValue)
local old = relate:GetStrong(inst, k)
if old == slotValue then
return -- 이미 같은 바인딩 — no-op, 다시 빠지고 다시 들어가지 않음
end
if old then
destroySlotTree(old) -- 폐기, 옮기지 않음 — 아래 "확정" 절 그대로
return -- 이미 같은 바인딩(retract가 방금 손 안 댄 경우) — no-op
end
attachSlot(slotValue, inst, inst, k)
relate:SetStrong(inst, k, slotValue)
end
function SlotHandler.retract(inst, k, v)
assert(v == nil, "Slot 자리가 더 이상 Slot이 아니게 될 때만 불림")
local old = relate:GetStrong(inst, k)
if old then destroySlotTree(old) end
relate:SetStrong(inst, k, nil)
if old and old ~= v then -- v는 nil일 수도, 대체하는 새 Slot 자체일 수도 있음
destroySlotTree(old) -- 폐기, 옮기지 않음 — 아래 "확정" 절 그대로
relate:SetStrong(inst, k, nil)
end
-- old == v(같은 Slot 재발행) → 아무 것도 안 함, 곧 process도 no-op으로 스킵
end
```
- **같은 바인딩이면 완전히 무시하는 게 이 자리에선 효율 문제가 아니라
정합성 문제** — `Tag`/`Ref`의 diff는 값이 같아도 기껏해야 헛계산만
하고 넘어가지만, Slot은 아래 "확정" 절대로 "폐기, 옮기지 않음"(portal
정합성 문제** — Slot은 아래 "확정" 절대로 "폐기, 옮기지 않음"(portal
없음)이 이미 정책으로 확정돼 있어서, 이 no-op 가드가 없으면 **재귀 재
emit이 있을 때마다 마운트된 서브트리 전체가 파괴됐다 다시 만들어짐**
(자식들이 들고 있던 스크롤 위치/포커스/애니메이션 상태 전부 유실) —
Tag의 diff가 막으려던 "깜빡임" 문제보다 훨씬 파괴적인 버전.
Tag의 `Contains` 힌트가 막으려던 "깜빡임" 문제보다 훨씬 파괴적인 버전.
`store.key:Set(sameSlotAgain)`처럼 사용자가 실수로 같은 객체를 다시
emit하거나, 상위 `:Compute`가 재계산됐는데 결과가 우연히 같은 Slot
레퍼런스인 경우 등이 실제로 이 경로를 탈 수 있음.
레퍼런스인 경우 등이 실제로 이 경로를 탈 수 있음. `retract``old ~= v`
체크로 이걸 막고, `process``old == slotValue` 체크로 대칭적으로 막음
— 둘 다 스킵돼야 완전한 no-op.
- Slot 핸들러 자신이 감시 중인 값(배열/스토어)이 바뀔 때 child를 갱신하는
추적(구독)도 `base/bind-system-plan.md`가 말하는 "process 함수가 다른 값
변경을 추적해도 됨" 범위에 속하고, `retract` 시점엔 그 추적만 풀면 됨 —

View file

@ -2,7 +2,9 @@
**상태**: base — 2026-08-08 세 번째 세션에서 값 모양을 전면 재설계(구
모델은 `archive/tag-hash-key-model-reversed.md`에 원문·역전 이유 보존).
새 결정만 반영, 열린 질문 없음.
2026-08-12 열한 번째 세션에 `TagHandler``process`/`retract` 메커니즘을
참조 카운트 기반으로 전면 정정(옛 버전은 `archive/
retract-always-fires-reversed.md`). 새 결정만 반영, 열린 질문 없음.
## 왜 재설계됐나
@ -50,54 +52,101 @@ Tag("selected") or nil end)`처럼 그냥 `nil`을 리턴하면 됨. `None` 센
이건 nil-hole 문제라 Tag만의 특수 규칙이 아니라 `props.Modifier`/
`props.Ref`와 같은 일반 array-part 관용구.)
## 메커니즘 — `TagHandler`, retract가 이제 의미 있어짐
## 메커니즘 — `TagHandler`, `retract`가 이제 의미 있어짐
구 모델과 달리 **핸들러 타입이 사이클마다 바뀔 수 있음**(`Tag(...)` ↔
`nil`, 값이 `Tag`가 아니게 되면 `TagHandler.isHandlable`이 더 이상 안
맞음) — 그래서 `retract`가 실제로 필요해짐(`bind-system-plan.md` "확정된
디스패치 모델" 절의 일반 원칙 그대로).
**[전면 정정, 2026-08-12 열한 번째 세션] 아래는 이전 버전(단일 `relate`,
`assert(v==nil)`, "Tag(A)→Tag(B)는 retract 안 불림")을 대체함 — 그 버전은
두 가지를 놓쳤음:**
1. **`retract`는 실제로 store 재발행마다(핸들러 타입이 안 바뀌어도) 항상
불림** — `bind-system-plan.md`의 "확정된 디스패치 모델" 절이 처음부터
말해온 대로 `StoreBind`가 재-dispatch 전에 무조건 `Dispatch.retractUnder`
부르기 때문. "Tag(A)→Tag(B)는 retract 안 불림"이라는 옛 서술은 틀렸음
(상세 근거는 `bind-system-plan.md` 일반 retract 계약 절, `archive/
retract-always-fires-reversed.md`).
2. **서로 다른 배열 위치의 두 `Tag(...)`가 같은 이름을 겹쳐 가질 수
있음**(`Frame { Tag("a"), Tag("a","b") }`류, 웹 `className="a a a"`
같은 합집합 시맨틱) — 한 위치의 diff만 보고 `RemoveTag`를 부르면 다른
위치가 아직 그 이름을 쓰고 있어도 지워버리는 참조 카운트 버그가
생김(사용자 지적, 2026-08-12 열한 번째 세션).
**둘 다 같은 해법으로 풀림**: `Tag`는 **immutable**이고(모든 연산이
clone을 반환) 내부에 State 같은 걸 담지도 않는 **항상 확정 상태인 말단
값**(Tween과 같은 결) — 그래서 `State<Tag>`가 진짜로 다른 내용을 내놓을
때마다 **항상 물리적으로 다른 `Tag` 객체**가 나옴. 이 사실 덕분에, 이름별로
"어떤 `Tag` 객체들이 지금 이 이름을 걸고 있는가"를 집합으로 추적하면
`retract`(이전 객체가 이 이름을 놓음)/`process`(새 객체가 이 이름을 걺)가
겹치는 이름/겹치는 위치 양쪽 다 자동으로 올바르게 처리됨:
```lua
local relate = Relate() -- TagHandler 전용, 이전에 반영한 Tag 값 저장
local kTagMap = Relate() -- {[inst(weak)] = {[k]: Tag}} — 위치별 마지막으로 반영한 Tag
local tagNameMap = Relate() -- {[inst(weak)] = {[tagName]: {[Tag]: true}}} — 이름별 현재 걸고 있는 Tag들
TagHandler.priority = <일반>
TagHandler.isHandlable(inst, k, v) = isTag(v) -- Brand 기반, array-part 전용
function TagHandler.process(inst, k, v)
local old = relate:GetStrong(inst, k)
-- diff: old에 있고 v에 없는 이름만 RemoveTag, v에 있고 old에 없는 이름만 AddTag
-- (모두 지웠다 다시 붙이지 않음 — 랙/스타일 깜빡임 방지가 이 diff의 존재 이유)
relate:SetStrong(inst, k, v)
function TagHandler.retract(inst, k, newv)
local oldv = kTagMap:GetStrong(inst, k)
if not oldv then return end
local newvIsTag = isTag(newv) -- newv는 nil일 수도, 대체하는 새 Tag 자체일 수도 있음
for name in oldv:Names() do
local holders = tagNameMap:GetStrong(inst, name) -- 이미 등록됐으므로 항상 있음
holders[oldv] = nil
if next(holders) == nil and not (newvIsTag and newv:Contains(name)) then
inst:RemoveTag(name) -- 곧 process가 재확정할 이름이면 실제 호출은 skip(깜빡임 방지)
end
end
end
function TagHandler.retract(inst, k, v)
assert(v == nil, "TagHandler.retract는 v가 nil일 때만 불려야 함")
local old = relate:GetStrong(inst, k)
if old then for name in old:Names() do CollectionService:RemoveTag(inst, name) end end
relate:SetStrong(inst, k, nil)
function TagHandler.process(inst, k, v)
for name in v:Names() do
local holders = tagNameMap:GetStrong(inst, name)
if not holders then
holders = {} -- strong map — Tag가 살아있는 동안 소유 목록도 살아있어야 함
tagNameMap:SetStrong(inst, name, holders)
end
if next(holders) == nil then
inst:AddTag(name)
end
holders[v] = true
end
kTagMap:SetStrong(inst, k, v)
end
```
- **`Tag(A) → Tag(B)`(같은 핸들러, 타입 안 바뀜)**: `retract`는 아예 안
불림 — `Dispatch`의 "핸들러가 안 바뀌면 retract 없이 process만 다시"
원칙 그대로(`bind-system-plan.md` "Dispatch 체인" 절). **diff는 여기,
`process` 안에서만** 일어남 — 전체 삭제 후 재생성하면 스타일이 순간
전부 사라졌다 다시 붙어 랙/깜빡임을 유발하므로(사용자 지적), 반드시
이전 값과 diff.
- **`Tag(A) → nil`(핸들러가 TagHandler → 없음으로 바뀜)**: `retract`
불림. **[명시화, 2026-08-09 열한 번째 세션] 전체 삭제는 정확히
`v == nil`일 때만 맞는 동작 — "v를 안 봐도 된다"가 아니라 "v가 항상
nil로 들어온다는 걸 알고 있으니 별도 분기가 필요 없다"가 정확한
표현.** Tag 값을 담는 키에서 TagHandler가 더 이상 매치 안 되는 유일한
경로가 값이 `nil`이 되는 것(`None → nil` 재디스패치 포함)이라 이
전제가 깨지지 않는 한 위 구현처럼 `v`를 실제로 분기 안 해도 항상
옳음 — 위 pseudocode에 `assert(v == nil, ...)`을 추가해 이 전제를
코드에도 드러냄. `Handler.retract`가 여전히 `(inst,k,v)` 3-인자를
받는 건 계약 일관성 때문이지(다른 핸들러는 `v`를 실제로 씀) Tag가
그걸 필수로 요구해서가 아님.
- **`AddTag`는 온전히 `process`, `RemoveTag`는 온전히 `retract`** — 서로
겹치는 diff 계산이 없음. `retract`가 이전 `Tag`(`oldv`)가 걸었던 이름
전부를 소유 목록에서 빼되(항상 실행), 그 결과 목록이 비었을 때 **실제
`RemoveTag` 호출만** "새로 들어올 `newv`가 그 이름을 여전히 Contains하는가"로
힌트를 줘서 skip — 소유 목록 자체는 항상 최신 객체로 갱신되므로(정확히
`oldv`를 빼고 `v`를 넣는 두 단계), 이름이 살아남는 경우에도 stale
레퍼런스가 안 남음. `process``v`가 새로 거는 이름 전부를 무조건
등록(소유 목록이 비어있던 경우에만 실제 `AddTag`) — 자기 나름의 old-vs-new
diff가 전혀 필요 없음(그 일을 `retract`가 매번 정확히 해줌).
- **`Tag(A)→Tag(B)`(같은 위치, 내용만 바뀜)**: `retract(inst,k,B)`가 먼저
불려 `A`가 걸었던 이름 중 `B`에 없는 것만 실제로 `RemoveTag`, 남은 건
힌트로 skip — 그 다음 `process(inst,k,B)``B`의 이름 전부를 등록(이미
걸려있던 이름은 `AddTag`가 no-op으로 재확인만 됨, 소유 목록엔 `B`가 새로
등록). 결과적으로 실제 `RemoveTag`/`AddTag` 호출은 진짜 변경된 이름에만
일어남 — 스타일 깜빡임 방지라는 원래 목적은 그대로 달성.
- **`Tag(A)→nil`**: `retract(inst,k,nil)`만 불림(값이 `Tag`가 아니게 돼
`process`는 매치 자체가 안 됨) — `newvIsTag=false`라 힌트가 항상
거짓이 되어 `A`가 걸었던 이름 전부가 무조건 실제로 `RemoveTag`됨(다른
위치가 그 이름을 계속 쓰고 있지 않다면).
- **여러 위치가 같은 이름을 겹쳐 가지는 경우**(`Frame { Tag("a"), Tag("a","b") }`):
두 위치가 서로 다른 `k`로 각자 독립적으로 `process`/`retract`를 타지만,
`tagNameMap["a"]`는 **양쪽 위치의 `Tag` 객체를 모두 담는 하나의 공유
집합** — 한쪽이 "a"를 잃어도 다른 쪽 객체가 집합에 남아있으면 실제
`RemoveTag`가 안 불림. 웹 `className`처럼 손실 없는 합집합이 정확히
나옴.
- **`retract`가 자기 위임 대상까지 수동으로 안 쫓아가도 됨** —
`Dispatch.retractUnder`가 체인 전체를 알아서 훑어주므로 TagHandler는
자기 자원(위 `relate` 저장분)만 정리하면 됨. 상세 메커니즘은
자기 자원(위 두 릴레이션)만 정리하면 됨. 상세 메커니즘은
`bind-system-plan.md` "Dispatch 체인" 절.
## 패키지 배치 — base는 값+API, roblox는 process/retract 글루

View file

@ -150,21 +150,23 @@ StoreBind가 State/Source 레이어를 전부 풀어낸 뒤의 값:
**GC-안전성은 기존과 동일** — `Relate``inst`로 weak-keyed되어 있어
`inst`가 죽으면 이 슬롯(엔진 Tween 객체+`Value` 포함)도 별도 정리 로직
없이 같이 GC됨. `retract`는 이 케이스에서 거의 안 불림 — 아래 절 참고.
없이 같이 GC됨. **[정정, 2026-08-12 열한 번째 세션]** `retract`는 store
재발행마다 항상 불리지만(`bind-system-plan.md` 일반 retract 계약 절
정정분 — "거의 안 불림"이었던 원 서술은 틀렸음), PropertyHandler의
`retract`는 몸체가 no-op이라 실질적으로 하는 일이 없음 — 아래 절 참고.
### 왜 `retract`가 더 이상 필요 없는가 — Dispatch 체인 관점의 결과적 단순화
기존 모델에선 "Tween 핸들러가 매치되어 애니메이션이 실행 중이었는데,
다음 값이 더 이상 Tween 대상이 아니게 되어 일반 PropertyHandler로
핸들러 *타입*이 바뀌는" 경우가 `base/bind-system-plan.md`가 서술하는
"`retract`가 실제로 의미를 갖는 유일한 패턴"의 대표 예시였음. 새 모델에선
핸들러 *타입*이 바뀌는" 경우가 이 문제의 대표 예시였음. 새 모델에선
**매치되는 Dispatch 핸들러가 항상 PropertyHandler 하나뿐**(Tween 여부는
값 내부 분기일 뿐 핸들러 매치 자체엔 영향 없음) — 이 시나리오 자체가
Dispatch 레벨에서 사라짐. 트윈 취소/전환은 위 3-상태 저장 로직으로
PropertyHandler 내부에서 처리 — Tag가 이미 하고 있는 "diff는 `process`
자신이 담당" 패턴과 같은 모양이라 새 개념 아님. (PropertyHandler의
값 내부 분기일 뿐 핸들러 매치 자체엔 영향 없음) — "핸들러 *타입*이
바뀌는" 시나리오 자체가 Dispatch 레벨에서 사라짐. 트윈 취소/전환은 위
3-상태 저장 로직으로 PropertyHandler 내부에서 처리. (PropertyHandler의
`retract` 필드 자체는 여전히 정의해둬야 함 — "필드 생략 불가" 규칙은
예외 없는 일반 규칙 — 다만 실제로 호출될 일이 이 경로에선 사실상 없음.)
예외 없는 일반 규칙 — **매번 불리긴 하지만** 몸체가 no-op이라 실질적
동작이 없음, 일반 프로퍼티는 애초에 "unset" 개념이 없어서.)
### 타입 대수: `T' = T | Tween<T>` — Modifier/State/Source에 새 타입 기계 불필요

View file

@ -89,12 +89,14 @@ Modifier 타입의 메소드 목록에 끼워 넣도록 챙기면 됨, 새로
일반 프로퍼티 핸들러와 달리 이 숏핸드는 실제 Instance를 만들어 붙이는
쪽이라 "`nil` = 셋 안 함"이 곧 "만들어둔 게 있으면 치운다"는 뜻이 됨.
- **이건 `retract`가 아니라 `process` 자신의 로직**`retract`는 "이
키를 다른 핸들러가 넘겨받는" 시나리오 전용(`bind-system-plan.md` "확정된
디스패치 모델" 절)이지, 같은 핸들러가 값이 바뀌어서 자기 산출물을
정리하는 것과는 다른 문제. 값이 나중에 다시 숫자로(`2`→`nil`→`3`처럼)
바뀌면 `process`가 다시 자식을 만들면 그만이라 `retract` 쪽에 별도로
구현할 게 없음.
- **이건 `retract`가 아니라 `process` 자신의 로직****[정정, 2026-08-12
열한 번째 세션]** `retract`는 store 재발행마다 항상 불리지만(핸들러
타입이 안 바뀌어도, `bind-system-plan.md` 일반 retract 계약 절 정정분
참고), 이 Handler는 `process(inst,k,v)` 자체가 `v``nil`이든 숫자든
전부 완결적으로 처리하므로(있으면 지우거나 만들거나) `retract`가 할 일이
없어 no-op이면 충분 — 일반 프로퍼티 핸들러가 `retract`이 필요 없는 것과
같은 이유. 값이 나중에 다시 숫자로(`2`→`nil`→`3`처럼) 바뀌면 `process`
다시 자식을 만들면 그만이라 `retract` 쪽에 별도로 구현할 게 없음.
- **캐비엇**: 이 왔다갔다가 잦으면(예: 반응형 State가 `nil`과 숫자 사이를
자주 토글) 매번 Instance 생성/제거 비용이 그대로 듦 — Tween처럼 무거운
API는 아니지만 공짜도 아니므로, 잦은 토글이 예상되는 값을 이 숏핸드에

View file

@ -0,0 +1,96 @@
# 2026-08-12 열한 번째 세션 — "retract는 항상 불림" 전면 정정, Tag 참조 카운트 재설계
## 배경
열 번째 세션(Attribute 이름 소유권)에 이어, 사용자가 같은 클래스의 문제가
`Tag`에도 있을 수 있다고 지적: 서로 다른 배열 위치의 `Tag(...)`가 같은
이름을 겹쳐 가질 수 있는데(`Frame { Tag("a"), Tag("a","b") }`, 웹
`className="a a a"`와 같은 합집합 시맨틱), 한 위치의 diff만으로 다른
위치가 아직 쓰는 이름을 지워버리는 참조 카운트 버그가 날 수 있다는
문제 제기.
## 논의 전개 — 근본 오류 발견까지
1. Claude가 처음 위치별 `{[k]: true}` set(참조 카운트)을 제안 → 사용자
동의.
2. 사용자가 후속 질문: "retract에서 새롭게 들어온 v가 Tag라면 Contains를
확인해서 재사용될 거면 RemoveTag 안 함" — 이게 성립하려면 `retract`
`v=Tag`(nil 아닌 값)를 받는 경우가 있어야 함. Claude가 처음엔 이걸
기존 `assert(v==nil)`과 모순된다고 판단해 "메커니즘을 바꾸자는
거냐"고 되물음.
3. 사용자가 재확인: "정확히 저는 어떤 값이든 덮여 쓰여지는 즉시 retract를
실행하는 거로 두기로 했었어요 — 전체 process 트랙을 retract하고
리빌드한다는 맥락이었어요."
4. Claude가 `bind-system-plan.md`의 "확정된 디스패치 모델" 절(2026-08-04
원문)을 재확인 — `Dispatch/StoreBind.luau`는 재-dispatch 전에
**무조건** `Dispatch.retractUnder`를 부른다고 이미 명시돼 있었음.
"핸들러 타입이 안 바뀌면 생략"이라는 조건은 어디에도 없었음 — 2026-08-07
여덟 번째 세션에 추가된 "retract가 의미 있는 유일한 패턴" 서술이
자기 문서 다른 절과 처음부터 모순돼 있었고, `Tag`
`assert(v==nil)`을 액면 그대로 믿고 거꾸로 일반 규칙을 잘못 추론한 게
오류의 실제 출처였음이 드러남.
## 파급 효과 확인
이 오류가 **이번 대화에서 만든 `Ref`(여덟 번째 세션)/`Slot`(아홉 번째
세션)/`Attribute`(열 번째 세션) 설계 전부에 그대로 이어받아져 있었음** —
셋 다 `assert(v==nil)`을 두고 "같은 핸들러 타입이면 process가 diff"라는
전제로 설계돼 있었기 때문. 사용자의 지적("retract는 v가 다른 값일 수
있는데, diff하는 모든 곳에서 is로 잘 테스트하고 있는지 봐야할듯")대로
전수 감사·수정 진행.
## 정정된 일반 원칙
`retract(inst,k,v)`는 store 재발행마다(핸들러 타입이 안 바뀌어도) 항상
불림 — `v``nil`일 수도 대체하는 새 값 자체일 수도 있음, `retract`
안에서 `v==nil`을 가정하면 안 됨. 대부분의 핸들러(PropertyHandler,
`NoneHandler`, UICorner 숏핸드)는 이 반복 호출에서 실제로 할 일이 없어
no-op이면 충분 — "타입 안 바뀌면 아예 안 불린다"가 아니라 "불리지만
몸체가 비어 있어도 된다"가 정확한 표현. 여러 위치가 하나의 실제
리소스(엔진 attribute/tag/mounted 서브트리 등)를 공유하는 핸들러는
`retract`가 이전 기여 제거(엔진 호출은 `v` 힌트로 skip 가능),
`process`가 새 기여 등록을 전담하는 분업이 자연히 나옴 — `process` 쪽에
별도 old-vs-new diff가 더 이상 필요 없어짐(그 일을 `retract`가 매번
정확히 대신 해줌).
## 각 파일 정정 내역
- **`base/tag-plan.md`**: `TagHandler` 메커니즘 전면 재작성 — 단일
`relate`(이름 집합)를 `kTagMap`(위치→Tag)+`tagNameMap`(이름→Tag
set) 두 릴레이션으로 분리. `retract`가 이전 Tag의 이름들을 무조건
`tagNameMap`에서 빼되, 실제 `RemoveTag`는 "새로 들어올 Tag가 그
이름을 여전히 Contains하는가" 힌트로 skip. `process`는 새 Tag의
이름을 무조건 등록(집합이 비어있던 경우만 실제 `AddTag`) — 자기
diff 불필요. `AddTag`는 온전히 `process`, `RemoveTag`는 온전히
`retract`로 완전히 분업. 여러 위치가 같은 이름을 겹쳐 가지는 경우도
공유 `tagNameMap` 집합으로 자동 해결.
- **`base/bind-system-plan.md`**: 일반 retract 계약 절 전면 재작성(위
"정정된 일반 원칙" 그대로). `Ref`의 retract 절 — `assert(v==nil)`
제거, "언바인딩은 retract 전담, 바인딩은 process 전담"으로 재설계(둘
`old==v`/`old~=v` identity 체크로 spurious 재발행 시 콜백 이중
발화 방지). `NoneHandler` 절의 "retract와 무관" 근거도 정정(불리긴
하지만 할 일이 없어서 no-op).
- **`base/slot-plan.md`**: "Slot과 Store 바인드의 관계" 절 — `destroySlotTree`
호출이 `process`에서 `retract`로 이동(`old~=v`면 폐기), `process`
마운트만 전담(`old==slotValue`면 no-op).
- **`base/attribute-plan.md`**: `AttributeKeyHandler.retract`
`v==nil` 가드 추가(그렇지 않으면 매 store 재발행마다 attribute가
잠깐 nil로 flicker). 그룹의 "남아있는 이름" 위임도 `retractUnder`
없이 `Dispatch.process`만 반복 호출하면 체인이 매번 새 항목을
쌓기만 해서(팝은 `retractUnder`의 일) 누적 누수가 생기는 걸 발견 —
남아있는 이름도 먼저 `retractUnder`(같은 캐싱된 키로) 부른 뒤에만
`process`하도록 정정.
- **`base/ui-shorthand-plan.md`, `base/tween-plan.md`**: 결론(해당
핸들러의 `retract`는 no-op)은 안 바뀌었으나 "retract가 아예 안
불린다"는 근거 서술만 정정.
- **`archive/retract-always-fires-reversed.md`** 신설 — 원문·역전
이유·영향받은 문서 전부 보존.
- **`README.md`** — 위 파일들 요약 라인 + 아카이브 표에 새 항목 추가.
## 남은 것
전수 감사는 base/ 전체 `retract` 언급 파일(관련 없는 `relate-plan.md`/
`module-lifecycle-plan.md`/`lifecycle-pattern.md`/`onchange-plan.md`
등)까지 훑었으나, `onchange-plan.md``OnChangeHandler`(Connection
disconnect/reconnect 패턴)는 애초에 공유 리소스가 없어 이 문제에
해당 안 됨 — 정정 불필요 확인만 하고 넘어감.

View file

@ -555,3 +555,27 @@ no-op 가드는 Tag/Ref보다 Slot에서 훨씬 중요함(가드 없으면 재
claimant 타입 없이 AttributeKey 객체 identity 자체를 재사용하는 더 적은
부품의 설계. 기존 diff 로직(사라진 이름만 nil, 남은/새 이름은 갱신)은
그대로 맞물림, 캐시가 그룹 값 교체를 넘어 영속돼야 한다는 조건만 명시.
**2026-08-12 열한 번째 세션 — "retract는 항상 불림" 전면 정정, `Tag`
참조 카운트 재설계** (`session/2026-08-12-11-retract-always-fires-correction.md`)
`Tag`도 Attribute와 같은 참조 카운트 문제(서로 다른 위치의 `Tag(...)`
같은 이름을 겹쳐 가질 수 있음, 웹 `className` 합집합)가 있다는 사용자
지적에서 출발 — 논의 중 사용자가 "retract가 v=Tag(nil 아님)를 받는
경우"를 전제로 설계를 제안했고, 이게 기존 `assert(v==nil)`과 모순됨을
Claude가 지적했으나, 사용자가 "덮여 쓰여지는 즉시 retract 실행, 전체
트랙을 retract하고 리빌드하는 맥락"이라고 재확인. `bind-system-plan.md`
자기 "확정된 디스패치 모델" 절(2026-08-04 원문)을 재대조하니 `StoreBind`
재-dispatch 전에 무조건 `retractUnder`를 부른다고 이미 명시돼 있었음 —
"핸들러 타입이 안 바뀌면 retract 생략"이라는 2026-08-07 정정 서술이
자기 문서와 처음부터 모순돼 있었고, `Tag``assert(v==nil)`을 액면
그대로 믿고 거꾸로 일반 규칙을 잘못 추론한 게 오류의 출처였음이 드러남.
**이 오류가 이번 대화에서 만든 `Ref`/`Slot`/`Attribute` 설계 전부에도
그대로 이어받아져 있었음** — 전부 한 세션에 정정: `retract`는 store
재발행마다 항상 불리고 `v`는 대체 값 자체일 수 있음(`nil` 가정 금지),
"이전 기여 제거는 `retract`, 새 기여 등록은 `process`"로 분업하면
`process`의 별도 diff가 필요 없어짐. `Tag``kTagMap`(위치→Tag)+
`tagNameMap`(이름→Tag set) 참조 카운트로 재설계(`AddTag`는 온전히
`process`, `RemoveTag`는 온전히 `retract`, `Contains` 힌트로 flicker
방지). `Attribute`의 그룹 위임도 "남아있는 이름"에서 `retractUnder`
생략하면 체인이 계속 쌓이는 누수를 추가로 발견·정정. 역전 사례는
`archive/retract-always-fires-reversed.md`에 원문·근거·영향 범위 보존.