사용자 요청("같이 하나하나 처리해나가보자. 질문 모드로 계속 물어보며")으로
발견 보고 `H-1`~`H-54`를 문항지로 만들지 않고 **갈래 선택이 필요한 것만 급한
순서로** 물어 전량 결정하고 `base/` 24개 문서에 반영했다. 결정과 근거는
`qa-request/pre-implementation-handtrace-round6-followup.md`가 소스이고,
진행 경위와 사용자 발언 원문은
`session/2026-08-24-01-handtrace-round6-resolution.md`.
**M2/M3를 막던 것이 전부 닫혔다** — 말단 핸들러 4종의 `setLength`/
`setOffsetSource` 미등록(`H-39`), `New(): Quad`가 닫힌 타입이라 `quad.Dispatch`가
타입에러인 것(`H-25`), `Effect`의 leaf 사망 cleanup 배선 부재(`H-11`),
`:List`의 좌표계 결함 둘(`H-1`/`H-2`).
## 구조가 바뀐 것 넷
- **`slot._elemIndex`**(물리 요소→인덱스 역방향 맵) 신설로 `indexOfRaw`가 O(1)
기본 경로가 되고, `:List`의 `keyIndex`는 **단순 키 집합(`prevKeys`)**으로
강등. 사용자 역제안 — *"raw* 가 층위를 알아야할 이유를 모르겠는 상태 …
realElem->index 해시맵을 만들어주고, index 밀고 당기는 동작에서 이걸 같이
업데이트해주는 편이"*. 맵을 `_elements`와 같은 층에 두니 층 분리가 오히려
깨끗해졌다
- **`_mounted`가 "물리 인스턴스 유무"만 뜻하게 좁혀지고 `slot._physicalTarget`
신설** — 상태가 셋이 됐다(미실체화/실체화/마운트). `raw*`는 부기를 실체화
시점부터 항상 하고 `native*`만 가른다. 그래야 최초 population 중
`getOffsetAt`이 성립해 `updateFn`의 `index`를 거기서 뽑을 수 있다
- **`Ref.Callbacks`가 해시맵 셋 + `:Uncallback`**(사용자 발견) — 해제가 O(1)이
되고 `ref-plan.md`의 `#t` border 실측 항목이 폐기됐다
- **`blocker:Policy(emit)` 노출** — `Debounce`/`Throttle`이 emit을 안 쥐고 자기
Blocker를 On/Off만 하는 정책이 된다. `Gate`엔 `Flush`/`Cancel`을 안 둔다
요소 타입 검증은 블랙리스트에서 **주입 술어 `isInst` 기반 화이트리스트**로
뒤집혔고(`H-40`), 주입 op이 둘 늘었다(`isInst`/`onDestroying` — 조합 폴백이
불가능해 미주입이면 에러).
## 사용자가 에이전트 갈래를 뒤집은 자리가 여럿
`H-1`(세 갈래가 전부 차선), `H-2`(*"부기 확정에서 length 를 확정해도 되는거
아님?"* — 부기와 물리 마운트를 분리하라는 되물음), `H-40`(브랜드 판정이
2026-08-21 인스턴스 브랜드 재작성 이후 성립 불가임을 지적), `H-33`(제 중첩
합성안이 unblock 시 디바운스 창을 새로 시작시켜 창이 안 끝난다는 지적).
`Ref.Callbacks` 해시맵화와 `Ref` 콜백의 `canExecute` 확인은 사용자가 먼저 발견.
## 반영 후 재검토 — `/code-review high` 7건 + 감사 9라운드 34건
**`/code-review high` 7건 중 셋이 이번 반영이 만든 회귀**였다 — 상태가 셋이
됐다고 산문에 쓰고 코드엔 경계 하나만 남겨 `Slot { frameA }` 생성자가
크래시하던 것, `native*`를 `_mounted`로 가리면서 그게 곧 파괴였다는 걸 놓쳐
영구 누수를 만든 것, "`:List`와 CRUD는 상호배타"라며 승인받은 분기가
**재마운트 경로를 안 봐서** 포탈을 깬 것. 셋 다 코퍼스 정합성 각도로는
구조적으로 안 보이는 종류라 *"`/code-review`는 감사자를 대체하지 않는다"*가
실측으로 재확인됐다. 같은 리뷰가 `H-11`의 두 결정이 서로 모순임을 잡아
재결정했다 — *"`EffectHandle`이 자기 `bindLifetime` 직후에 건다"*는 **그 호출부가
실재하지 않았고**, 사용자 판단으로 `bindLifetime`/`unbindLifetime`이 `isEffect`를
보고 직접 처리하는 것으로 바뀌었다.
**`quad-doc-auditor` 감사 루프는 9라운드에서 새 발견 0건으로 수렴**(라운드별
5→7→2→2→3→9→2→4→0, 각도와 목록은 followup의 E절). 한 턴에 하나씩 돌리고
라운드마다 각도를 바꿨다. 가장 많이 잡은 6라운드(9건)는 *"이 체크박스로 코드를
짜면 무엇이 나오는가"*를 물은 라운드였고, 그때 `ROADMAP.md`의 미완료 항목이
대거 stale인 게 드러났다(폐기된 `pos` 공식이 "확정"으로, 접두합 캐시 무효화
계약이 통째로 부재, `bindLifetime`이 "둘만 한다"고 적혀 `H-11`과 직접 모순).
**반복된 실패 패턴은 하나 — "고쳐야 할 자리가 N개인데 일부만 고쳤다"**:
배너를 달고 그 배너가 부정하는 문장을 안 고침(1라운드), `base/` 19개를 바꾸고
`.claude/README.md`를 한 줄도 안 고침(2라운드), 그 README를 고칠 때 11행 중
6행만(7·8라운드). 셋 다 핸드오버 체크리스트가 명시적으로 경고하는 항목이라,
규율이 없어서가 아니라 지켰는지 스스로 확인하지 않아서 생긴 실패다.
## 백로그 하나
`Fallback`/`Traceback` 중 생성된 부분 트리의 회수(`H-26`) — 그 둘이 슈가라
구현 시점에 같이 다룬다. 같이 확인된 것: `dispatch-core-plan.md`가 잔여 부기를
인스턴스 GC가 정리한다고 적은 문장은 **gcconn 불멸성과 양립하지 않는 틀린
안전망 주장**이라 삭제했다.
**M2 착수를 막는 설계 항목은 이제 없다** — 남은 건 `question.md` 2번(M2↔M3
양방향 의존, 마일스톤 순서)뿐이다. `doc-check.py` ERROR 0.
Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01Jjrec9xAS7TZstMx5gi3cm
22 KiB
Tag — array-part 값 객체, 참조 카운트는 base / 엔진 호출은 주입 op
✅ [2026-08-13 열네 번째 세션] 하강 diff 재디스패치 반영 + 패키지 재배치 완료. 이전 ⚠️ 배너가 예고하던 교체가 끝났음: (1) 클로저가 받는 값의 타입이 계약으로 보장되어
isTag(hintValue)방어 가드가 불필요해졌고 깜빡임 방지가 깊은 체인에서도 유지됨 (base/dispatch-core-plan.md"Dispatch 체인" 절), (2) 참조 카운트 알고리즘 전체가 quad-base 소속이 되고addTag/removeTagop만 백엔드가 주입(아래 "패키지 배치" 절). 옛 모델 원문은archive/dispatch-hintvalue-model-reversed.md.
상태: base — 값 모양/이름은 확정. 2026-08-08 세 번째 세션에서 값
모양을 전면 재설계(구 모델은 archive/tag-hash-key-model-reversed.md에
원문·역전 이유 보존). 2026-08-12 열한 번째 세션에 TagHandler의
process/retract 메커니즘을 참조 카운트 기반으로 전면 정정(옛 버전은
archive/retract-always-fires-reversed.md). 2026-08-12 열다섯 번째
세션에 Added/Removed를 단일 이름에서 string | {string}으로
정정(아래 값 모양 절). [2026-08-13 열네 번째 세션] 재디스패치 모델
(0-A)과 패키지 배치까지 반영 완료 — 이 문서에 남은 열린 질문은 이름
자체(용어 정리 대기열)뿐.
왜 재설계됐나
구 모델([Tag "Name"] = boolean, 태그 하나당 해시 파트 키 하나)은 상호
배타적인 스타일 상태(btn1/btn2/btn3류, 실사용에서 20개까지도 가능)를
표현하려면 태그 이름 개수만큼 키를 각각 갱신해야 해서 끔찍함, 스타일
조합(여러 태그를 합쳐 쓰는 것)도 구조적으로 안 됨 — 상세 경위는
archive/tag-hash-key-model-reversed.md.
값 모양 — Modifier와 같은 immutable clone 체이닝
Tag(name1, name2, ...) -- 생성자, 가변인자. Tag() 빈 값도 유효
tag:Added(name: string | {string}): Tag -- clone 후 이름(들) 추가, 원본 안 건드림
tag:Removed(name: string | {string}): Tag -- clone 후 이름(들) 제거
tag:Contains(name): boolean -- 멤버십 확인
tag:Names(): iterator<string> -- 담고 있는 이름 순회(아래 "메커니즘" 절이 쓰는 것)
tag:Apply(factory): U -- factory(self) 체이닝 설탕(Modifier와 동일 패턴)
Tag.Merged(tag1, tag2, ...): Tag -- 여러 Tag의 합집합(무손실). Modifier의
Overridden(필드 단위 덮어쓰기, 손실 있음)와
다른 연산이라 이름도 다름 — Overridden은
"이미 계산된 걸 합침", Merged는 "집합을
합침"
Added/Removed가 -ed 어미인 이유는 Add/Remove로 쓰면 뮤테이션
API처럼 보이기 때문 — 실제로는 항상 table.clone 후 반환(Modifier
3번 절과 동일한 immutable 확정 이유: 형제 서브트리 오염 방지).
[정정, 2026-08-12 열다섯 번째 세션, 같은 세션 후속 재정정] Added/
Removed는 vararg가 아니라 string | {string} — 단일 이름 또는
이름 배열을 받고 내부에서 type(v) == "table"이면 순회(flatten)해서
처리. 처음엔 이름 여러 개를 한 clone으로 처리하려고 vararg
(Added(name, ...))로 정정했으나, 사용자가 실사용 패턴을 지적하며
재검토됨 — 단순히 "번거로움" 정도가 아니라 Lua 문법상 실제로 못 하는
경우가 생김: table.unpack(t)는 그 호출식이 인자 목록의 맨 끝(tail)
위치일 때만 여러 값으로 펼쳐지고, 그 뒤에 다른 인자가 오면(또는 다른
table.unpack 호출이 뒤따르면) 첫 값 하나로 잘림 — 그래서 조건절로 여러
개의 독립된 동적 이름 테이블을 만든 뒤(namesA, namesB, ...) 그걸 한
Added 호출로 합쳐 넘기는 건 vararg로는 애초에 표현이 안 됨(마지막
테이블만 완전히 펼쳐지고 나머지는 각각 첫 이름만 반영됨) — 결국 호출부가
먼저 테이블 하나로 합친 뒤 table.unpack을 딱 한 번만 쓰거나, 아예
테이블을 그대로 넘기게 해야 함. 반면 string | {string}은 그 테이블을
그대로 넘기면 끝(여러 동적 테이블도 table.move/table.insert로 먼저
합치기만 하면 그대로 통과) — 호출부가
단일 이름이든 이미 조립해둔 배열이든 분기 없이 통일해서 부를 수 있고,
구현도 table.unpack 없이 단순 type(v) == "table" 분기 후 for
순회만 있으면 됨. 부작용 걱정 없음 — 받는 값이 전부 이미 확정된
plain string이라(핸들러 계층 값처럼 identity/생명주기가 얽힌 값이
아님) 테이블로 감싸 넘기든 아니든 의미가 완전히 동일, 오버로드가
모호해질 여지가 없음. Tag(name1, name2, ...) 생성자는 그대로 vararg
유지(정적 리터럴 호출 자리라 동적 조립 문제가 없음) — 내부적으로
{...}로 한 번 패킹해 self:Added(packed)(단일 clone, 테이블 인자
경로 재사용)를 호출하는 것으로 구현.
children 배열 슬롯(array-part)에 직접 놓임 — Frame { Tag("selected") }.
정적으로 여러 개 놓아도(Frame { Tag("a"), Tag("b") }) 각자 독립적으로
자기 태그만 추가하면 되므로 Merged 없이도 됨(Merged/Added/Removed는
"하나의 Tag 값을 프로그래밍적으로 조립"하는 용도).
동적 토글은 Source/State로, None 불필요 — 상호배타 상태 전환은
store.activeTag:Compute(function(name) return (if name:Get() == "btn1" then Tag("selected") else nil) end)처럼 그냥 nil을 리턴하면 됨. None
센티널은 "정적 테이블 리터럴에서 키 = nil이 키 없음과 구별 안 되는"
문제의 해법이지(dispatch-core-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가 이제 의미 있어짐
구 모델과 달리 핸들러 타입이 사이클마다 바뀔 수 있음(Tag(...) ↔
nil, 값이 Tag가 아니게 되면 TagHandler.isHandlable이 더 이상 안
맞음) — 그래서 retract가 실제로 필요해짐(dispatch-core-plan.md "확정된
디스패치 모델" 절의 일반 원칙 그대로).
[전면 정정, 2026-08-12 열한 번째 세션] 아래는 이전 버전(단일 relate,
assert(v==nil), "Tag(A)→Tag(B)는 retract 안 불림")을 대체함 — 그 버전은
두 가지를 놓쳤음:
- 반환하는 클로저는 store 재발행마다(핸들러 타입이 안 바뀌어도) 항상
불림 — "Tag(A)→Tag(B)는 retract 안 불림"이라는 옛 서술은 틀렸음
(
archive/retract-always-fires-reversed.md). [갱신, 2026-08-13 열네 번째 세션] 부르는 주체는 이제StoreBind의 선행retractFrom이 아니라Dispatch.process자신임 — 같은 핸들러면 그 자리 클로저에 새 값을 넘기고 곧바로process를 다시 부름(dispatch-core-plan.md"Dispatch 체인" 절 (A) 분기). 호출 빈도는 그대로. - 서로 다른 배열 위치의 두
Tag(...)가 같은 이름을 겹쳐 가질 수 있음(Frame { Tag("a"), Tag("a","b") }류, 웹className="a a a"와 같은 합집합 시맨틱) — 한 위치의 diff만 보고RemoveTag를 부르면 다른 위치가 아직 그 이름을 쓰고 있어도 지워버리는 참조 카운트 버그가 생김(사용자 지적, 2026-08-12 열한 번째 세션).
둘 다 같은 해법으로 풀림: Tag는 immutable이고(모든 연산이
clone을 반환) 내부에 State 같은 걸 담지도 않는 항상 확정 상태인 말단
값(Tween과 같은 결) — 그래서 State<Tag>가 진짜로 다른 내용을 내놓을
때마다 항상 물리적으로 다른 Tag 객체가 나옴. 이 사실 덕분에, 이름별로
"지금 이 이름을 걸고 있는 위치(k)가 몇 개인가"를 집합으로 추적하면
retract(이전 위치가 이 이름을 놓음)/process(그 위치가 새 이름을 걺)가
겹치는 이름/겹치는 위치 양쪽 다 자동으로 올바르게 처리됨:
[정정, 2026-08-13 네 번째 세션] holders는 Tag 객체가 아니라 위치(k)로
키잉함. 최초안은 holders[Tag객체] = true(객체 identity 기준)이었으나,
Tag는 immutable이라 재사용이 자연스러운 관례(예: local SELECTED = Tag("selected")를 모듈 상수로 만들어 여러 위치에서 재사용)인데, 객체
identity로 홀더를 추적하면 같은 객체를 두 위치(k1, k2)에 걸었을 때
tagNameMap엔 단일 엔트리만 생겨 두 위치가 구분이 안 됨 — k1만
retract돼도 그 하나뿐인 엔트리가 지워져 holders가 비고, k2가 여전히
그 이름을 쓰고 있는데도 RemoveTag가 불려버리는 실제 참조 카운트 버그로
손 트레이싱에서 재현됨(하단 "여러 위치가 같은 이름을 겹쳐 가지는 경우"
절이 암묵적으로 "서로 다른 객체"만 가정하고 있었던 게 원인). "여러 위치가
같은 이름을 겹칠 수 있다"는 이 절의 원래 취지 자체가 위치 기준
집합이어야 성립하므로, 홀더를 k로 바꾸는 게 원 의도와도 더 맞음:
[정정, 2026-08-13 다섯 번째 세션] process가 자기 retract 클로저를
반환하는 계약으로 전환되며 kTagMap(위치별 마지막 Tag)이 완전히
불필요해짐 — retract가 필요로 했던 "이 위치에 걸려 있던 Tag가
뭐였는가"는 이제 그 process 호출이 반환하는 클로저가 v를 upvalue로
직접 캡처하므로, 별도 저장소에서 다시 조회할 이유가 없음(위
base/dispatch-core-plan.md "핸들러 내부 상태 저장" 절 — 단발성 handoff는
클로저로 충분). tagNameMap(이름별 현재 걸고 있는 위치 집합)은 여전히
필요 — 이건 서로 다른 여러 위치를 가로지르는, process/클로저 하나의
호출 수명을 넘어서는 누적 상태라 Relate가 맞는 경우:
local tagNameMap = Relate() -- {[inst(weak)] = {[tagName]: {[k]: true}}} — 이름별 현재 걸고 있는 위치들
-- TagHandler 자신은 `.priority` 없음(직접 등록 안 됨) — 아래 "패키지 배치" 절의
-- `TagFallbackHandler = { priority = HANDLER_PRIORITY_FALLBACK, isHandlable = TagHandler.isHandlable,
-- process = TagHandler.process }`가 실제로 등록되는 얇은 래퍼(2026-08-14 열두 번째 세션 정정)
-- [정정, 2026-08-24 6라운드 손 트레이싱 `H-52`] **`type(k) == "number"` 가드 추가.**
-- 주석은 "array-part 전용"이라 말하면서 실제 판정은 값만 봤다. `RefLeafHandler`가
-- 2026-08-18에 정확히 같은 버그를 고친 전례가 있다 — 빠지면 named 자리로 흘러온
-- 값을 잡으려는 `HANDLER_PRIORITY_FALLBACK` 가드가 죽은 코드가 된다.
TagHandler.isHandlable(inst, k, v) = (type(k) == "number" and isTag(v))
function TagHandler.process(inst, k, v, index)
-- [2026-08-24 6라운드 `H-39`] **말단 핸들러의 배열 자리 부기** — 빠져 있었다.
-- `Tag`는 물리 리프를 하나도 기여하지 않으므로 짝을 맞춰 `0`. 없으면
-- `Frame { Tag("card"), TextLabel { … } }`처럼 Tag를 자식보다 앞에 두는
-- **아주 흔한 배치**가 첫 `recompute`에서 `sourceList[k]가 nil`로 죽는다
-- (`base/dispatch-core-plan.md`의 등록 책임 절). 그룹 `Attribute`와 함께
-- "Length/Offset 비참여 카테고리"로 재정의하는 갈래도 있었으나 `bk.N`의
-- 의미가 바뀌어 파급이 커서 기각(사용자 확정 2026-08-24).
Dispatch.setOffsetSource(inst, k, None)
Dispatch.setLength(inst, k, 0, inst)
local added = {}
for name in v:Names() do
local holders = tagNameMap:GetStrong(inst, name)
if not holders then
holders = {} -- strong map — 이 이름을 거는 위치가 하나라도 있는 동안 소유 목록도 살아있어야 함
tagNameMap:SetStrong(inst, name, holders)
end
if next(holders) == nil then
table.insert(added, name) -- 이 이름을 처음 거는 위치일 때만 실제 호출 대상
end
holders[k] = true -- 이 "위치"가 이 이름을 걺(Tag 객체가 다른 위치와 같아도 무관)
end
if #added > 0 then
addTag(inst, added) -- 한 번에 — 웹 className 일괄 갱신을 위한 배치 계약
end
return function(nextValue)
-- nextValue는 nil(단순 철거)이거나 같은 핸들러가 곧 처리할 새 Tag —
-- 타입이 계약으로 보장되므로 isTag 가드가 필요 없음(2026-08-13 열네 번째 세션).
if v == nextValue then return end -- Tag는 immutable이라 객체가 안 바뀌면
-- 이름 집합도 절대 안 바뀜 — holders 순회 자체가 불필요한 순수 최적화
local removed = {}
for name in v:Names() do
-- [정정, 2026-08-14 리뷰] 생존 이름은 **홀더 등록 자체를 유지**한다.
-- 예전엔 홀더를 일단 빼고 removeTag 호출만 skip했는데, 그러면 곧
-- 이어지는 process가 빈 holders를 보고 `addTag`를 **다시** 부름
-- (엔진이 멱등이라 안 보였을 뿐, "진짜 바뀐 이름에만 호출"이라는
-- 이 절의 설계 목표와 어긋났고 아래 서술과도 모순이었음).
if nextValue ~= nil and nextValue:Contains(name) then continue end
local holders = tagNameMap:GetStrong(inst, name) -- 이미 등록됐으므로 항상 있음
holders[k] = nil -- 이 "위치"가 이 이름을 놓음(같은 Tag 객체를 다른 위치도 쓰고 있어도 무관)
if next(holders) == nil then
table.insert(removed, name)
end
end
if #removed > 0 then
removeTag(inst, removed) -- 한 번에
end
end
end
addTag는 온전히process,removeTag는 온전히 반환하는 클로저 — 서로 겹치는 diff 계산이 없음. 클로저는 이전Tag(v, 자기 자신이 캡처)가 걸었던 이름 중 새 값이 더 이상 안 거는 것만 소유 목록에서 빼고, 그 결과 목록이 빈 이름들을 모아removeTag를 한 번 호출함. 생존 이름은 소유 목록을 그대로 두므로([정정, 2026-08-14 리뷰] 예전엔 일단 뺐다가 호출만 skip했는데 그러면 곧이은process가 빈 목록을 보고addTag를 다시 불렀음) 뒤이은process에서addTag자체가 안 불림 — "실제 엔진 호출은 진짜 바뀐 이름에만"이 코드 수준에서도 성립.process는v가 새로 거는 이름 전부를 무조건 등록(소유 목록이 비어있던 경우에만 실제addTag) — 자기 나름의 old-vs-new diff가 전혀 필요 없음(그 일을 클로저가 매번 정확히 해줌).Tag(A)→Tag(B)(같은 위치, 내용만 바뀜):A를 처리했던process가 반환한 클로저가nextValue=B로 먼저 불려A가 걸었던 이름 중B에 없는 것만 실제로removeTag, 남은 건 skip — 그 다음process(inst,k,B,index)가B의 이름 전부를 등록(이미 걸려있던 이름은 소유 목록이 안 비어 있어addTag자체가 안 불림, 소유 목록엔B의 위치가 그대로 유지). 결과적으로 실제removeTag/addTag호출은 진짜 변경된 이름에만 일어남 — 스타일 깜빡임 방지라는 원래 목적은 그대로 달성. [범위 확대, 2026-08-13 열네 번째 세션] 이 깜빡임 방지는 이제 깊은 체인에서도 성립함 — 옛 모델에선 힌트가retractFrom이 지목한 한 자리에만 전달돼서State<State<Tag>>의 바깥이 재발행하면 TagHandler가nil을 받아 전량RemoveTag후 재AddTag했으나, 하강 diff에선 각 레벨이 자기 재프로세스에서 자기 값을 받으므로 인덱스가 얼마나 깊든 TagHandler는 진짜Tag객체를 받음(dispatch-core-plan.md"Dispatch 체인" 절의 "깊은 체인에서도 힌트가 안 사라짐").Tag(A)→nil:A의 클로저가nextValue=nil로만 불림(값이Tag가 아니게 돼 핸들러가 바뀌므로Dispatch.retractFrom경로) —Contains검사가 항상 거짓이 되어A가 걸었던 이름 전부가 무조건 실제로removeTag됨 (다른 위치가 그 이름을 계속 쓰고 있지 않다면).- 여러 위치가 같은 이름을 겹쳐 가지는 경우(
Frame { Tag("a"), Tag("a","b") }): 두 위치가 서로 다른k로 각자 독립적으로process/자기 클로저를 타지만,tagNameMap["a"]는 양쪽 위치(k)를 모두 담는 하나의 공유 집합 — 한쪽이 "a"를 잃어도 다른 쪽 위치가 집합에 남아있으면 실제removeTag가 안 불림. 웹className처럼 손실 없는 합집합이 정확히 나옴. 위치 기준이므로 두 위치가 물리적으로 같은Tag객체를 재사용해도(흔한 관례) 정확히 같은 방식으로 안전 — 이게 바로 위 "정정" 절에서 객체 identity 기준을 버린 이유. - 클로저가 자기 위임 대상까지 수동으로 안 쫓아가도 됨 —
Dispatch.retractFrom이 체인 전체를 알아서 훑어주므로 TagHandler는 자기 자원(위tagNameMap하나)만 정리하면 됨. 상세 메커니즘은dispatch-core-plan.md"Dispatch 체인" 절.
패키지 배치 — 값도 알고리즘도 quad-base, 주입되는 건 addTag/removeTag (2026-08-13 열네 번째 세션 재배치)
Tag의 값 타입/clone 체이닝 API(Tag(...)/:Added/:Removed/
:Contains/:Apply/Merged/:Names)가 quad-base인 건 처음부터 그대로
(Modifier와 같은 층위 — 엔진 무관, 순수 데이터+연산). [재배치,
2026-08-13 열네 번째 세션] 여기에 더해 TagHandler(위 참조 카운트
알고리즘 전체)도 quad-base로 옮김 — 예전엔 CollectionService 실제
호출이 있다는 이유로 핸들러 통째로 quad-roblox였으나, 엔진에 종속된 건
AddTag/RemoveTag 두 줄뿐이고 tagNameMap 참조 카운트는 순수 부기임.
웹에도 대응물이 있으므로(className 합집합) 그 배치대로면 같은 참조
카운트 알고리즘을 백엔드마다 재구현하게 됨.
addTag(inst: any, names: {string}): () -- 백엔드가 주입
removeTag(inst: any, names: {string}): ()
-
{string}을 받는 이유: 호출자는 항상 quad 자신이고 넘기는 것도 "이번 사이클에 실제로 추가/제거된 이름 집합"이라 테이블이 자연 단위임. vararg면table.unpack(t)가 인자 목록 tail 위치일 때만 완전히 펼쳐진다는 Lua 문법 제약에 걸리는데, 이건 이미Tag:Added가 vararg →string | {string}으로 되돌아갔던 것과 같은 이유(위 "값 모양" 절). 배치 호출 자체는 테이블로도 되므로 웹className일괄 갱신 요구도 그대로 충족됨. -
등록 우선순위는
HANDLER_PRIORITY_FALLBACK— 단 여기 꽂히는 건TagHandler자신이 아니라 그걸 감싸는TagFallbackHandler(2026-08-14 열두 번째 세션 정정 —TagHandler는 참조 카운트 알고리즘 구현일 뿐, 스스로 등록되는 주체가 아님). [재역전, 2026-08-18 구현 전 QA] 등록 주체는 백엔드 팩토리가 아니라 quad-base 자신(모듈이 자기 레지스트리를 구성하는 시점) — 백엔드를 아직 로드하지 않은 상태에서도 "provider가 초기화됐는지 확인하라"는 안내 에러 경로가 돌아야 하기 때문이고, 이건InitNamespace거부 원칙과 충돌하지 않는다(근거는base/dispatch-core-plan.md의 "base가 소유하는 핸들러와 주입되는 엔진 op" 절이 소스). 경위는archive/tag-attribute-load-time-registration-reversed.md.addTag/removeTag만 백엔드 팩토리가 채우는 타입 계약, 안 채운 슬롯의 base 기본값은 명시적으로 에러내는 스텁. 더 명확한 메시지나 진짜 원자적 실패를 원하는 백엔드는 opt-in으로HANDLER_PRIORITY_FALLBACK + 1짜리 가로채기 Handler를 추가로 등록 가능. 태그 처리 자체를 통째로 다른 알고리즘으로 바꾸고 싶은 백엔드는HANDLER_PRIORITY_FALLBACK보다 확실히 높은 우선순위로 자기 Handler를 등록하면 baseTagFallbackHandler를 완전히 대체함(같은 override 원리). 상세는base/dispatch-core-plan.md의 "base가 소유하는 핸들러와 주입되는 엔진 op" 절 —Attribute도 정확히 같은 구조 (base/attribute-plan.md). 래퍼는 필드 셋뿐인 얇은 재노출:TagFallbackHandler = { priority = HANDLER_PRIORITY_FALLBACK, isHandlable = TagHandler.isHandlable, process = TagHandler.process, } -
이건 새 아키텍처 개념이 아니라 이미 확정된 "base는 인터페이스/값, backend는 구현"(
LifetimeHandle의bindLifetime/canExecute,Dispatch.addHandler자체가 그 패턴)을 핸들러 층까지 밀어붙인 것.
열린 질문
값 모양/메커니즘/패키지 배치엔 [2026-08-13 열네 번째 세션 기준] 없음 —
재디스패치 모델(question.md 0-A)과 패키지 재배치까지 전부 반영 완료.
남은 건 이름 자체(Tag/Added/Removed/Merged)가 용어 정리
대기열(.claude/question.md 1번)에 있다는 것뿐.