발견 17건(H-107~H-123)의 사용자 결정을 base/ 전체에 반영. 7라운드 확정 중 뒤집힌 건 없고, 고친 건 전부 7라운드가 base/에 내려앉을 때 생긴 누락·충돌 (하루 차로 확정된 결정들이 서로를 못 본 자리)이다. 결정의 소스는 qa-request/pre-implementation-handtrace-round8-followup.md. 계약 변경 넷: - Ref 콜백이 fn(value, ref) — 2번째가 곧 출처 Epoch. Effect가 Update(from)에 넘길 유일한 통로였다(k(value)뿐이면 Update(nil) 크래시, 실측 재현). - Observer fn이 세 자리 fn(targetState, self, emitFrom) + observer._state 강참조. 옛 2-인자는 "self는 리시버" 계약과 정면 충돌해 무인자 state:Observer()의 내부 콜백이 즉사했다. - WeakSubscribe도 .Subscribed를 세운다. 안 그러면 Effect의 State dep 전량이 조용히 침묵. 해제는 "건 경로로 푼다"(양방향 fail-fast). - 예약 키 진단이 CheckReservedKeys<keyof<T>> — T를 통째로 넘기는 배선은 실사용 T에서 아예 안 돈다(Source<T>가 *error-type*을 품어 유효한 Store 전부에 스퓨리어스 에러). 사용자가 문항의 전제를 두 번 정정: Ref 콜백과 Observer 콜백은 이질적이라 애초에 통합 대상이 아니었고(Observer엔 자기 epoch가 없다), H-118은 소유권 문제가 아니라 gate-plan 5번의 문장이 틀린 것이었다(🟡→🟢). 커밋 전 검증 — 감사 11라운드(44건, 0건으로 수렴) + /code-review high 7라운드(42건) = 86건. 감사가 0으로 수렴한 직후 code-review가 42건을 냈고, 그중 하나가 H-101의 "새 필드를 안 만든다"를 역전시켰다: getOffsetAt의 부수효과가 splice의 되감기 신호를 지우는 경로가 실재해, 부기 필드를 offsetCacheValidUpTo(캐시)와 offsetSetUpTo(:Set 완료) 둘로 분리했다. "Set을 해줬느냐"와 "캐시가 유효하냐"를 한 값이 쥔 게 원인이었다. 그 외: splice 무효화 i-1, 명시 recompute 호출부 전부 재진입 게이트, recompute 되감기 클램프, Store defaults isSource 검증, isModifier 가드를 Source 생성자로, 훅 슈가 nil 가드, pesde.lock 커밋 확정, :Single 3-인자. 2026-08-25 session/ 원문 공백은 2026-08-19 선례대로 재구성 없이 기록만. doc-check ERROR 0 / WARN 기준선 유지. Claude-Session: https://claude.ai/code/session_01F9zgJ4c4kDitAoQMm9qxKn Co-authored-by: qwreey <me@qwreey.moe>
52 KiB
7라운드 손 트레이싱 발견 — 사용자 결정과 반영 결과
무엇인가: .claude/qa-request/pre-implementation-handtrace-round7.md의
발견 52건(H-55~H-106)과 그 검증 패스
(qa-request/pre-implementation-handtrace-round7-verification.md)를 사용자와 대화형으로 처리한 결과. 결정의 소스는
이 문서이고, 발견 원문은 위 두 파일이 소스다(여기서 다시 서술하지 않음).
진행 방식: 검증 패스가 재편성한 결정 단위 12묶음(🅐~🅜) 순서를 따랐다. 같은 묶음 안의 항목은 결론이 서로를 규정하므로 같이 물었다.
[2026-08-25] 결정·반영 전부 완료. 12묶음을 순서대로 처리했고 base/
반영까지 끝났다(doc-check.py ERROR 0). 처분 요약:
| 처분 | 건수 | 번호 |
|---|---|---|
확정 — base/ 반영 |
39 | H-55H-72, H-79H-82, H-84H-92, H-94H-99, H-101~H-106 |
| 무효/소멸 | 4 | H-73(<<T>>가 값 호출부에서 동작함) · H-74·H-75·H-76(Store 재설계로 WrapStore 폐기) |
| 기각 | 1 | H-77(RunInit 사례 — 성립 안 하는 사용법) |
| 범위 축소 | 2 | H-87(🔴→🟡) · H-105(수치 정정) |
| 다른 항목으로 흡수 | 6 | H-64/H-70(Ref=Epoch) · H-102(역참조 조회) · H-93/H-98(_hold) · H-83 |
부수로 닫힌 것 둘 — question.md 최우선의 중간 State GC(_hold
불변식)와 GetDynamic 위치(콜론 + CheckReserved). M2 착수를 막는
항목이 하나도 남아 있지 않다.
새 표면: Ref.Revision(=Epoch) · Ref:WeakCallback ·
Observer:WeakSubscribe/:WeakUnsubscribe · EpochMap:Peek ·
store:Of/:Names + CheckReserved · emit(commit) -> boolean ·
bk.recomputeBlocker · EffectHandle:Rerun(정의) · _consumeCleanup
폐기: WrapStore/ProcessStoreType · _installing · rawInvalid ·
_refDeps/_refCallbacks/_observers(→ _deps 하나) · Store의 lazy 우선 모델
역전 없음 (⚠️ [2026-08-26 정정, 8라운드 처리 중 발견] 여기 한때
*"역전: store.key = value 부활"*이라고 적혀 있었는데 그건 같은 날 오전에
넣었다 철회한 Store 재설계의 서술이 요약 머리에 남은 것이다 —
archive/store-value-field-redesign-withdrawn.md의 대조표가
*"store.key = v 부활 → 폐기 유지"*라고 명시하고 todos.md 00번도
"역전 없음"이라고 적는다. store.key = value 폐기(2026-08-06)는 유지된다)
툴체인: scripts/relink.sh + scripts/test.sh 신설 — luau CLI가
심볼릭 링크를 못 탄다는 것이 최소 재현으로 밝혀졌다(H-78).
🅐 게이트 정책의 상태 접근 통로 — H-55 · H-86 · H-72 · H-63
H-55 + H-86 — emit에 인자와 반환값을 준다 (확정)
setup: (emit: (commit: boolean?) -> boolean) -> (onUpstreamEmit: () -> ())
emit()/emit(true)— 평소대로 흡수 집합을 flush하고 전파.emit(false)— 흡수 집합을 버리고 전파하지 않는다(Trailing = false,Cancel,OffWithoutEmit이 요구하던 "버리기").- 반환값 — "실제로 내보내거나 버릴 게 있었는가"(= 흡수 집합이
비어 있지 않았는가). 정책이
pending을 따로 안 들고도 "지금 쌓인 게 있나"를 읽는 유일한 통로.
H-55의 갈래 (b)와 H-86의 갈래 (a)를 그대로 합성한 것. H-49의
*"setup 시그니처는 안 바뀐다"*는 인자 목록은 유지한 채 최소로만
되짚는다(인자가 늘지 않고 기존 인자에 선택 파라미터와 반환값이 붙는다).
Throttle의 onWindowEnd가 이걸로 닫힌다:
if not emit() then -- 보류분 없었음
window = nil -- 완전 idle 복귀 → 타이머 체인 종료
else
rearm()
end
H-72 — EpochMap:Peek(from) -> boolean 추가 (확정)
갈래 (a). 읽기 전용 비교(저장된 리비전과 비교만 하고 덮지 않음).
Update가 이미 {읽기, 비교, 쓰기}라 Peek은 그 앞 두 개만 쓰는 것이고
내부 코드 공유가 쉽다. GateNode:_receive가 이걸 쓴다:
local valueChanged = self.valueEpochMap:Update(from)
local emitChanged = self.emitEpochMap:Peek(from) -- 갱신 안 함
H-63 — 세 자리 모두 선례대로 (확정)
- onunblock 핸들 보관은 weak-키 해시맵 셋
{[handle] = true}(__mode = "k") —H-7이Ref.Callbacks에 한 것과 동일. 배열의 구멍/ipairs조기 종료 문제가 소멸한다. - 강한 주인은 정책이 반환하는
onUpstreamEmit클로저다 — 그 클로저가 onunblock 핸들을 upvalue로 잡는다. 체인은GateNode → onUpstreamEmit → onunblock 핸들이고, Blocker 쪽은 weak이라 게이트 노드가 죽을 때만 수거된다. Blocker가 핸들을 강하게 드는 안은 기각 — 오래 사는 Blocker 하나가 거기 걸렸던 모든 gated state와 상류 체인을 영원히 살려두므로(:List항목마다 게이트를 무는 패턴에서 직행 누수). Off()/OffWithoutEmit()은 스냅샷을 뜬 뒤 순회한다(H-23과 동일).
🅑 전파 루프를 코드로 확정 — H-56 · H-62 · H-61
H-56 — 한 집합 + 구독자 종류별 분기 (확정)
구독자 집합은 하나({[subscriber] = true}, weak-키)이고, 원소는
Observer 값이다 — emit 클로저가 아니다(bindLifetime이 그 identity를
쓰므로 필수). lifecycle-pattern.md (4)의 "Observer의 emit 클로저" 표현을
고친다.
function State:_emitDown(from)
local snap = {} -- H-23 스냅샷
for sub in self._subs do snap[#snap+1] = sub end
for _, sub in ipairs(snap) do
if isState(sub) then -- 자식 노드
sub:_receive(from) -- §4 규칙 1~3, canExecute 안 봄
elseif canExecute(sub) then -- Observer / Effect
sub.fn(sub, from)
end -- 거짓이면 조용히 건너뜀
end
end
두 집합(_childNodes/_observers)으로 나누는 안은 기각 — emit마다 스냅샷이
두 번이 되고(H-92의 할당 비용 두 배), 등록/해제 경로도 둘로 갈린다.
H-62 — eager(생성 즉시 등록) (확정)
사용자 판단: "생성 즉시 밖에 없다. 옵져버가 실행 안 된다면 get 자체가
안 되므로, lazy 하면 아예 등록 될 기회가 없다." — lazy가 성립하려면
"먼저 Get()이 일어난다"가 전제인데, Get()을 부르는 주체가 바로 그
등록되지 못한 Observer라 순환이다.
:With/:Compute/:Gate/:Block은 만들어지는 그 자리에서 상위의
구독자 집합에 들어간다. state-epoch-plan.md §4의 생성 시점 시딩,
blocker-plan.md의 "호출되는 즉시 … 등록", source-state-plan.md의
":With도 새 State 노드로 확정" 절과 이미 일치한다. lazy를 논거로 쓰는
"왜 State 체인을 Modifier처럼 플래튼하지 않는가" 절을 고친다.
⚠️ eager이므로 question.md 최우선의 "중간 State GC" 미해결이 더
절실해진다(엣지가 있어도 상위가 하위를 weak로만 들면 노드가 사라진다).
H-61 — 무인자 state:Observer()의 내부 콜백은 self:Get() (확정)
state:Observer() == state:Observer(function(self) self:Get() end).
no-op 콜백은 Get()을 안 부르므로 문서가 적은 용도(previous 패턴의
mutate 로직을 계속 돌게 하기)를 못 한다. 호출부 서술은 그대로 두고 내부
콜백만 못박는다.
🅔 Effect 배선 — H-57~H-60 · H-64 · H-65 · H-70
이 묶음은 대화 중에 원문의 갈래 어느 것도 아닌 구조로 수렴했다. 사용자가
두 도구를 제안했고(Ref의 Epoch 승격, Weak* 등록 표면), 그 둘이
H-58/H-64/H-70/H-59를 한 번에 닫았다.
확정된 구조 — 강한 주인은 항상 Effect, 발화 게이트는 canExecute 하나
Effect ──강──▶ { [Ref | State] = fn | Observer } ← 강한 주인은 언제나 Effect
Ref.Callbacks ──약──▶ fn (:WeakCallback)
Observer 전역 레지스트리 ──약──▶ Observer (:WeakSubscribe)
발화 게이트: 전부 canExecute(handle) 하나로
사용자 결론: "그냥 간단하게 저 강한 map 을 Effect 가 가지고, WeakSub/WeakUnsub 를 WeakCallback 처럼 넣어줍시다. 의미론은 같습니다. callback 을 잡고 있지 않거나, sub 대상인 observer 를 잡고 있지 않으면 gc 될 수 있다. 그러면 bindLifetime 은 effect 하나 구현을 한 이후 canExecute 로 모두 처리한다. 간단해집니다."
- dep마다 바인드/언바인드에서 등록·해제하던 춤이 통째로 사라진다.
bindLifetime/unbindLifetime은Effect핸들 하나에만 적용되고, 내부 Observer와Ref콜백의 발화 여부는canExecute(handle)이 전담한다. - 그래서
H-7의 *"unbindLifetime과:Unsubscribe()에서:Uncallback한다"*는 필요 없어진다(ref-plan.md의 "해제는 누수를, 게이팅은 발화를 막는다" 중 앞쪽 절반을Weak*가 대신한다).
새 표면 — Ref:WeakCallback / Observer:WeakSubscribe·:WeakUnsubscribe
Weak*가 프리미티브이고 강한 쪽이 그 위에 얹힌다. 사용자 확정: "동작 자체는 Weak 아닌것과 동일하게 가고, 가드도 동일하나 단순히 gc 안 되도록 킵 해주는 부분만 제거된 함수가 됩니다. 따라서 내부적으론 Weak 를 구현해 두고, Weak 아닌 곳에서 Weak 를 수행하고 gc 처리만 두면 돼요." →Callback(fn) = WeakCallback(fn) + 강한 셋에 킵,Subscribe() = WeakSubscribe() + 강한 레지스트리에 킵. 구현이 한 벌.- 왜
WeakRef가 아니라 이것인가: 사용자 지적 — "Ref 안에 항상 콜백이 쌓인다는것도 문제가 됨."WeakRef는 "Ref가Effect를 붙든다"만 풀고 "Ref.Callbacks에 죽은 클로저가 쌓인다"는 못 푼다.WeakCallback은 둘 다 푼다 —Effect ↔ cb순환이 자기완결이라 Luau GC가 통째로 수거하고,Ref쪽 항목도 같이 사라진다. WeakRef는 만들지 않는다(사용자: "안 만들어야겠습니다. 아이디어만 기록."). 아이디어 원문:WeakRef:Set(v)/:Get()만 주고 (.Value가 아닌 이유는 내부 값이 항상 있다고 확정된 상태가 아니라서), 내부는setmetatable({}, {__mode = "v"})의 1-슬롯.- 이건 🅐-3(
H-63)에서Blocker의 onunblock 핸들에 내린 결정과 같은 패턴이다 — 강한 주인은 소비자 쪽, 등록처는 weak.
H-58 + H-64 + H-70(중복) — Ref를 Epoch로 승격 (확정)
사용자 제안: "혹은 Ref 까지도 Epoch 를 구현해줘도 좋다는 생각. '바뀌였나?' 보는건 source 에 대한 계약이라, 똑같이 실제 값을 가지는 Ref 도 이를 구현해주는데 문제가 없음."
state-epoch-plan.md §2가 이미 예상해둔 확장이다 — "Source가 아닌
원천(외부 시계 등)이 특수 분기 없이 낀다 … EpochBrand:register(self) 한
줄로 끝난다."
Ref가 공개 필드.Revision을 갖고EpochBrand에 등록된다.:Set()이Source와 같은bit32.bnot(-rev)로 갱신..Callbacks(푸시 경로)는 그대로 —Epoch는 부기일 뿐이다.- 그래서
EffectHandle._epochs가 State/Source/Ref를 균일하게 담고, 포탈 캐치업이 dep 종류에 따라 갈리던H-64가 대칭이 된다. H-70의 "같은Ref중복"도EpochMap키 dedup으로 공짜로 닫힌다 (사용자: "이미 dedup 해주는 도구는 Epoch 가 있고, 그걸 그대로 적용하는 사안으로 공짜로 해결됨").
H-58 — 등록 구간 억제는 Blocker (확정)
사용자 지적: "해당 맥락의 도구인 Blocker 가 존재함 … 이미 Slot 에서 사용중임. 모든 옵저버와 callback 등록에 있어서 이를 수행해야할 것임. 옵져버도 처음에 호출하고, 여러 state 를 넣을 수 있음에 유의할것."
이 문제는 Ref dep 전용이 아니다 — source-state-plan.md가 확정한
"fn은 등록 시점에 즉시 1회 실행된다" 때문에 State dep이 여러 개인
Effect도 똑같이 중복 실행된다.
Effect가 사적Blocker하나를 든다. 등록 구간 동안On(), 끝나면OffWithoutEmit(). 즉시-1회 호출 경로가 전부blocker:IsOn()이면 조기 리턴.materializeSlotTree가 쓰는 관용구와 같은 모양이다.- 별도
_installing플래그는 폐기 — 그건 생성자 구간만 덮어 바인드 구간을 놓쳤다.
H-64 — 캐치업은 조건부, 그리고 미설치면 재설치 (확정)
if self._cleanup == nil or self._epochs:Refresh() then self:Rerun() end
사용자 판단: "cleanup 함수가 있냐 없냐를 보긴 해야겠네요. 기본적으로 cleanup 의 실행은, cleanup 필드를 읽은 다음 그 필드를 제거해버리고 그 다음 cleanup 을 실행해야할 것으로 보입니다. 그러면 cleanup 클로저가 있냐 없냐로 클린 해야하는지 알 수 있어요. 그리고 cleanup 클로저를 지우는건, 이 역시 gc 에 중요한 부분이겠네요."
- "바인드 때 항상 실행"이 아니라 **"설치돼 있지 않으면 설치"**다. 그래서
아래
H-65가 요구하는 재설치와 포탈 캐치업이 같은 한 줄로 닫힌다.
H-57 — retract가 직접 cleanup을 부른다 (확정)
갈래 (a). Observer/Effect Leaf dedup의 retract 클로저에서
nextValue ~= v 분기가 unbindLifetime(v) 뒤에 isEffect(v)면 cleanup을
소진 호출한다. 소진(필드 읽기 → 필드 제거 → 실행)이 있으므로 파괴
경로와 이중 호출이 없고, H-11이 확정한 *"unbindLifetime은 cleanup을
부르지 않는다"*는 안 건드린다.
H-60 — Rerun() 정의: 재진입은 지연 재실행 (확정)
사용자 판단: "Effect 의 실행 안에서 뭔가 수행되어 rerun 해야할 상황이 발생하면, 지연해 두었다 나중에 재실행 하는건 어떤지(실행이 끝나고 나서). 실제로 Effect 안에서 state 등을 바꾸는 상황은 react 등지에서 흔함. 유일한 문제는 error 발생 시 어떻게 되느냐인데, 그냥 UB로 두는게 맞아보임. 에러가 난 이후 데이터의 무결이 깨져도 별 책임 안 진다는 quad의 일반 동작이라서."
function EffectHandle:Rerun() -- 공개 메소드, 무인자
if self._running then
self._pending = true -- 실행 중 재진입 → 지연
return
end
self._running = true
repeat
self._pending = false
local c = self._cleanup
self._cleanup = nil -- 읽고 → 지우고 → 실행 (소진)
if c then c() end
self._cleanup = self.fn(self)
until not self._pending -- 재요청이 또 오면 또 돈다
self._running = false
end
canExecute확인은 호출부가 한다(Ref콜백·전파 루프가 이미 그렇게 한다). 사용자가fn안에서 직접 부르는 경로는 게이트하지 않는다.- error 시 UB — 전파되고 복구하지 않는다. 🅒(예외 안전성) 묶음의 원칙과 같다.
- 수렴 책임은 사용자
fn에 있다 — 무한 루프는 UB.
H-59 — :Subscribe()는 셋을 다 한다 (확정)
(a) handle.Subscribed = true + 전역 레지스트리에 핸들 자신 등록
(_observers만 등록하는 해석은 폐기 — Effect(fn)이 GC돼 cleanup이
유실된다), (b) 내부 Observer 각각 :WeakSubscribe(), (c) Ref
:WeakCallback(). (b)(c)는 위 구조상 생성자에서 이미 끝나 있고,
:Subscribe()가 새로 하는 일은 (a)뿐이다.
H-70 — deps 검증 (확정)
nildep → error.select("#", ...)로 순회해 구멍을 실제로 본다.- 중복 dep → 무시(error 아님). 사용자 근거: ":With 이나 시소한
연산으로 다른 State 가 된다던가 하면 deps 가 겹쳐도, 근원 source 가 겹쳐도
에러를 안 냄. Ref 도 유사한 부분." —
EpochMap키 dedup이 처리한다. - State/Source/
Ref가 아닌 값 → error.H-40이 요소 검증을 화이트리스트로 뒤집은 것과 같은 성격.
H-65 — 재바인드는 재설치, 재사용은 팩토리 패턴 (확정)
-
재바인드: 위
H-64의 한 줄이 그대로 답이다 —_cleanup이 없으면Rerun으로 재설치한다. 죽음을 표시하는 별도 부기를 만들지 않는다 (사용자 지적: "파괴 클린업은 결국 inst.Destroying 에 이벤트 바인딩인데 이 바인딩도 파괴 이후 자동 삭제된다 … gchold 나 gcconn 도 알아서 잘 풀린 상태라, 그냥 가만히 두면 삭제 이후 다시 사용에 있어 다시 실행해줘야한다는 것 이외엔 아무 문제가 없어요. 즉시 에러를 내려면 뭔가 다른 행동을 해줘야합니다만. 그걸로 얻는게 있느냐? 에 대해서는 의문입니다."). -
재사용/다중 인스턴스는
Effect팩토리를 넘기는 패턴으로 안내한다. 검토 중에Effect:Clone()/Effect<UD>:Userdata()/Effect.Template안이 차례로 나왔으나 전부 기각됐다 — 사용자 결론: "차라리 Effect 를 만들어내는 팩토리를 넘기는 패턴을 권장해야할듯 해요 … Clone 도, Userdata 도, 템플릿도 필요하지 않다."local function TimerEffectFactory(data: { timerSource: Source<...> }): Effect return Effect(function(self) ... return function() ... end end, data.timerSource) -- 주입받은 것을 그대로 deps로도 쓸 수 있다 end- 왜 이게 더 나은가(사용자): 초기 1회 실행 문제가 자연히 해결되고,
템플릿이 실행되어 찌꺼기로 남는 것을 막으려 따로 뭘 할 필요가 없다.
자식의 계약은
({...}) -> Effect하나이고, 부모가 더 큰 타입을 넘겨도 부분 성립으로 해결된다. 무엇보다 주입받은Source/Ref를 그대로deps로 넣을 수 있다 — userdata로는 절대 안 되던 것이다 ("이건 ud 가 deps 에 대해서는 아무 처리가 못 했던것과 비교해 더 간단하면서도, 기능적임"). - 이미 있는 관례다 — modifier에서 같은 패턴을 권해왔다.
- 부수로 *"이팩트를 여러곳에 바인딩하면?"*도 자연히 해결된다(매번 새 인스턴스).
- 왜 이게 더 나은가(사용자): 초기 1회 실행 문제가 자연히 해결되고,
템플릿이 실행되어 찌꺼기로 남는 것을 막으려 따로 뭘 할 필요가 없다.
자식의 계약은
이 묶음에서 안 만들기로 한 것
WeakRef, Effect:Clone(), Effect<UD>:Userdata() / SetUserdata·
GetUserdata, Effect.Template. 전부 검토 후 기각 — 위 각 절이 근거의
소스다.
🅓 "긴 연산의 꼬리가 도중 변경을 덮어쓴다" — H-85 · H-101 (+ H-102)
H-85 — rawInvalid 불린을 캐시 카운터 쌍으로 교체 (확정)
원문의 갈래 (a)(rawInvalid = false를 fn 앞으로)는 불충분하다 —
사용자 지적: "이러면 대신, 캐시가 언제 생성된 캐시인지 모르는 이슈가
발생하지 않아? 특히 에러가 난다고 하면, 다시 계산 안하고 이전 결과를 다시
쓰겠네?" 그리고 "state 는 epoch 를 구현해선 안 돼. 중간이지, 초기 값
컨테이너 계층은 아니거든. 따라서 cache count 를 넣을것을 추천해."
-- 생성 시
self.cacheTargetCount = 1
self.cacheCurrCount = 0 -- 달라서 "재계산 필요"
-- 무효화(§4 규칙 1) — 옛 `rawInvalid = true` 자리
if valueChanged then
self.cacheTargetCount = bit32.bnot(-self.cacheTargetCount)
end
-- 재계산
local gen = self.cacheTargetCount -- fn 직전 스냅샷
self.cache = self.fn(self, self.cache, ...)
for _, d in self.deps do d:_track(self.valueEpochMap) end
self.cacheCurrCount = gen -- ← 성공했을 때만
-- 재계산 판정 — 옛 `rawInvalid == true` 자리
if self.cacheCurrCount ~= self.cacheTargetCount then 재계산 end
- 재계산 도중 도착한 무효화는
cacheTargetCount를 앞서게 만들어 다음Get이 반드시 재계산한다(H-85본체). fn이 던지면cacheCurrCount가 안 갱신되므로 계산된 적 없는 캐시를 유효하다고 확신하는 일이 없다(갈래 (a)가 못 막던 것).- 증가는
bit32.bnot(-n)랩 — §2가Source.Revision에 확정한 그 한 줄을 그대로 쓴다. 비교가~=뿐이라 랩이 무해한 것도 똑같고, uint32 안에 머무르므로+1이 갖는 2^53 포화 지점 자체가 없다. - State는 여전히
Epoch를 구현하지 않는다 — 이 카운터는 자기 재계산 부기이지 남이 키로 삼는 리비전이 아니다(§4가 State dep에 대해TrackFrom(dep.valueEpochMap)을 쓰는 것과 일관).
H-101 + H-102 — 재진입은 Blocker로 막고, 커서는 invalidAfter로 되감는다 (확정)
먼저 원문의 두 서술을 정정한다.
- "
:List의updateFn이offset을 인자로 받는 사용자 코드라 거기서 형제 Slot을 조작" — 틀렸다. 사용자 정정: "updateFn 자체를 재실행 하는건 아니거든. offset 을 state 로 넘겨주어서 옵져빙 하게 만들잖아." 남는 트리거는 그보다 좁은 것 —slot.OffsetState를 관측하는 사용자 코드. - "바깥 루프의 남은
sum이 낡는다" — 대체로 틀렸다. 사용자 정정: "offset:Set 동작 자체가 무언가를 트리거 한다면, 그게 다 끝난 다음 돌아옴 … sum 이 더해지는 시점, 그러니까 offset 설정 이후라면, 이미 length 는 확정값임." 실제로 재현된 오작동은 길이가 더럽혀진 게 아니라bk.N이 자란 것이었다(for i = 1, bk.N의 상한이 진입 시 한 번만 평가됨).
확정된 구조:
- Blocker 두 개. 기존 배치 게이팅용(
drive/materializeSlotTree가 켜는 것)과 재진입 차단용을 따로 둔다(사용자 확정: "그냥 blocker 두개 쓰세요."). 합치면 배치Off()의 onunblock 순회 도중 같은 Blocker가 다시 꺼져 핸들이 재귀한다 —blocker-plan.md가 네스팅을 의도적으로 미지원한 것과 충돌. - 길이 변경 경로는 스킵으로 끝난다. 바깥 루프가
offset:Set(i)직후에contribution(i)를 읽으므로 그 Set이 유발한 자식 길이 변경은 이미 반영된 값이다 — 다시 도는 건 순수 낭비(사용자: "안 돌아도 되는데"). - ⭐ 구조 변경은 되감는다. 사용자 판단: "정말 앞에서 슬롯이
당겨졌다고 쳐요. 순차 순회로 처리가 안되는게 …
{a,a,a, b,b, c,c}여기서 a,a 두개가 소멸했는데, 이미 c 에 왔다면, b,b 가 c,c 로 덮여지고 a,a 는 달라지는게 없을 가능성이 생기죠. 따라서 recompute 도중 변경이 생긴다면, 변경이 생긴 곳으로 위로 올라가야할것 같습니다. 따라서, 지금 리컴퓨팅 중인 인덱스를 바꿀 방법을 제공하는게 나아보여요. 그러면 bk.N 에 대한 문제도 알아서 해결될것 같아보입니다." - 되감기 신호는 기존
bk.invalidAfter하나로 통일한다 — 새 필드를 안 만든다. 두 뜻("캐시가 여기까지 유효"와 "여기 다음부터 다시 해야 함")이 실제로 같은 것이기 때문. **재개 지점은invalidAfter + 1**이다 (사용자 지적: "length 가 바뀐 쪽에서, 본인의 offset 은 여전히 유효하다는거죠." —getOffsetAt의for i = bk.invalidAfter, at - 1과 같은 읽기). splice는j가 아니라j - 1로 낮춘다 — 밀린 자리는 자기 offset도 다시Set해야 하므로.
function recompute(bk, ownerKey)
bk.recomputeBlocker:On()
local prefix, i, sum = {}, 1, 0
while i <= (bk.N or 0) do -- 상한 매 반복 재평가 → N 증가 흡수
prefix[i] = sum
bk.offsetCache[i] = base + sum
bk.invalidAfter = i -- 여기까지 유효해짐
bk.offsets[i]:Set(base + sum) -- ← 사용자 코드가 돌 수 있는 자리
sum += contribution(bk, i)
if bk.invalidAfter < i then -- 누군가 낮췄다 → 되감기
i = bk.invalidAfter + 1
sum = prefix[i]
else
i += 1
end
end
ownerKey.Length:Set(sum)
bk.invalidAfter = bk.N or 0
bk.recomputeBlocker:OffWithoutEmit()
end
H-102가 이걸로 같이 닫힌다. 원문은 "splice가 observer를 옮겨도 클로저에 박힌 position 인덱스는 안 고쳐진다"였는데, 사용자 지적대로 *"이미slot._elemIndex: realElem → index 를 관리중"*이므로gatedRecompute는 인덱스를 캡처하지 않고 조회한다. 그 역참조를 Dispatch 층위로 격상해bk가 소유한다(사용자: "그것을 dispatch 로 격상시키는게 더 나아보이는 지점") — splice가 배열을 당길 때 같이 갱신되므로slot-plan.md의 splice 요구 목록에 항목이 늘지 않는다.bk.invalidAfter = 0으로 뭉개는 안은 기각(사용자: "0 으로 두면, 모든 부분에 있어 캐시가 무관해져요").- 참고 —
gatedRecompute가 왜 게이트 앞에서invalidAfter를 낮추나: recompute가 스킵돼도getOffsetAt의 lazy 접두합 캐시는 무효로 표시돼야 다음 조회가 다시 계산한다(원문 주석 "나중 emit도 같은 무효화가 필요"). recompute와는 다른 축이다.
🅕 Relate 되참조 — H-71 · H-77
H-71 — dedup 기록을 SetWeak으로 낮춘다 (확정)
갈래 (b). dedup은 순수 성능 최적화라(그 절이 스스로 *"correctness 문제는
아님"*이라고 못박음) 엔트리가 조기 소실돼도 "dedup을 한 번 놓친다"까지가
최대 손해다. v는 gchold가 이미 강하게 잡고 있고, relate-plan.md의
"다른 곳에서 안전하게 유지되는 것은 항상 SetWeak" 규칙에도 그대로
맞는다. 대상은 RefLeafHandler.process와
ObserverEffectLeafHandler.process.
같이 할 것: luau-test/done/07에 되참조 케이스를 음성 대조군으로
추가한다 — 지금 그 파일은 "GC-native 아키텍처의 핵심 전제를 검증했다"고
여러 문서에 인용되는데 실제로는 안전한 모양만 봤다.
H-77 — RunInit 사례는 기각, Relate 규칙만 명문화 (확정)
사용자 정정: "그런 경우 자체가 날 수 없음 … initFn 은 모듈을
인자로 받음. 그건 클로저 캡쳐가 아님. 게다가 initFn 자체가 항상 하나야.
그래야지 재진입이 방어되거든. 그 의미는, initFn 자체는 프로토에 불과하다는거임.
게다가, 그 안에서 만들어낸 함수들이 module 을 레퍼런싱 해도, 리턴 값이
quad자신에 flatten 되어 뮤테이션 되어 들어가는 구조 상, 그냥 module.fn
수행하는거랑 다른게 없음."
-
원문의 재현 코드(
q:RunInit(function() q.tagB = true end))는 성립하는 사용법이 아니다 — 인라인 클로저는 매번 새 identity라 멱등 가드 자체가 무의미해진다. 발견이 자기 전제를 깨뜨린 경우. -
quad-base/src/init.luau와runInitRelate설계는 그대로 둔다(최상위Relate, quad 인스턴스가 바깥 키,initFn이 내부 키).:23주석도 그대로. -
다만
Relate의 슬롯별 강/약 규칙 자체는 문서에 없으므로,H-71이 어차피 다시 쓰기로 한 "위험한 패턴" 절에 세 슬롯 표를 넣는다:슬롯 SetStrongSetWeak바깥 키( inst)weak weak 내부 키( key)강함 강함 값( value)강함 weak 지금 그 절은 위험을 값 기준으로만 서술해 내부 키 슬롯이 아예 등장하지 않는다.
🅖🅗 Store 표면 · 확정 타입 vs 확정 관용구 — 진행 중
⛔ H-73 — 무효. <<T>>는 값 호출부에서 동작한다 (실측으로 뒤집힘)
사용자 지적으로 재실측했다 — Luau에는 generic type instantiation
문법이 있고(luau.org/types/generics/#generic-type-instantiation), 값
호출부에서도, 콜론 메소드에서도 동작한다.
--!strict
type Source<T> = { Value: T, Revision: number }
type Store = { GetDynamic: <T>(self: Store, name: string) -> Source<T>, [string]: any }
local store = (nil :: any) :: Store
local ok: Source<number> = store:GetDynamic<<number>>("x") -- ✅ 진단 없음
local bad: Source<string> = store:GetDynamic<<number>>("y") -- ✅ 정확히 걸림 (진짜 묶임)
local none: Source<number> = store:GetDynamic("z") -- Source<unknown>
원문과 검증 패스는 인스턴스화를 생략한 호출만 돌려보고
*"Luau엔 호출부 명시 타입 인자 문법이 없다"*로 단정했다(ident<number>(1)이
비교 연산자로 오파싱되는 건 맞지만 <<>>는 다른 문법이다). 확정 표기
store:GetDynamic<<T>>(name): Source<T>는 그대로 성립한다.
따라서: (1) question.md 최우선 항목은 "타입을 묶을 수 있는가"가 아니라
원래의 예약 키 축으로 되돌아온다. (2) H-74(eager defaults 경로가
__index를 우회해 예약 키 방어를 무력화)는 살아 있다. (3)
quad-types-plan.md의 이중 꺾쇠 관례는 타입 자리 전용이 아니다 — 그
서술도 같이 넓혀야 한다.
⭐⭐ Store — WrapStore 폐기, 명시적 초기화, 타입 함수 안 씀 (확정)
⚠️ 이 항목은 같은 날 두 번 바뀌었다. 오전에 "
store.key가 값이고store:Of(k)가 프리미티브"라는 재설계를 넣었다가 같은 날 철회했다(감사 4패스가store:Names()의 런타임 구현 불가를 잡은 게 발단). 철회된 시도의 원문과 이유 다섯은archive/store-value-field-redesign-withdrawn.md. 아래는 최종이다.
발단: 사용자가 test.luau로 직접 타입 실험을 돌려, WrapStore/
ProcessStoreType로 결과 타입을 합성하는 접근이 H-75/H-76의 두
한계에 걸린다는 걸 확인하고 대안을 탐색했다.
최종 확정 형태 — 타입 함수를 안 쓴다:
type Store<T> = T & {
Of: <U>(self: any, name: string) -> Source<U>, -- 동적 키 전용(옛 GetDynamic)
Names: (self: any) -> { string },
}
local store = quad.Store<<{
hp: Source<number>,
name: Source<string>,
}>>({
hp = Source(100),
name = Source(""),
})
store.hp:Get() -- 평범한 레코드 필드 접근
store.hp:Set(5)
store.hp:Compute(function(s) ... end) -- 콜백 파라미터 추론 살아있음
store:Of<<boolean>>("dynamicName") -- 동적 키
- 타입 인자에
Source<T>를 직접 쓴다 —store.key가 평범한 레코드 필드라 타입 함수가 하나도 안 들고, 읽기/쓰기 의미론이Source의 기존 계약 그대로다. 사용자 지적: "왜 우리가Source()를 직접 넣는걸 거부하고, 이렇게 까지 하려 했죠? 단순히,Store<{ a: Source }>로 두고,store.a:Set,Get하지 말아야할 이유가 있을까요?" - ⭐ 명시적 초기화 — 옛 lazy
__index(없는 키를 그 자리에서 만들어 저장) 폐기. 그래서defaults가 곧 선언 키 집합이 되고store:Names()가 성립한다(H-79). 부모가 값을 다 안 넘겨도 되게 하려면 컴포넌트가 자기DEFAULTS로 채워 넘긴다. store.key = value는 폐기 유지 — 2026-08-06 결정 그대로. 오전 재설계에서 되살렸다가 같이 철회했다. 사용자 지적이 그 논거를 실측해줬다: ".Value = 1이 정적 쓰기처럼 보일텐데, 여기서 error 터지는 trace 가 나오면 당황스럽기도 하고요, 약간 마법적 동작이기도 해요."- 동적 키는
store:Of<<T>>(name)하나 — 옛GetDynamic을 흡수했다 (표면 둘을 유지할 이유가 없다). - 예약 키는
Of/Names둘뿐이고, 충돌하면 타입 검사가 조용히 꺼진다(실측).CheckReserved타입 함수가 진단만 띄운다 —error()가 아니라print(...)+return types.never. - ⭐ 여기서 원칙 하나가 나왔다 — "타입 함수는 타입이 못 잡는 문제를
에러로 띄우는 정도 이상으로 가지 않는다"(사용자).
index<>/keyof<>도 Luau가 predefine한 타입 함수라 같은 함정을 갖는다는 지적이 근거다.base/typing-limits.md§0으로 승격. Compute/Apply의 반환 타입은 여전히 명시 주석이 필요하다 — Store와 무관한 §1의 문제 B이고 어느 모양에서든 조용히 unsound다(실측: 틀린 주석이store.hp든 독립Source든 양쪽 다 안 걸림). 살아나는 건 콜백 파라미터 쪽이고, 철회된Self제네릭을 거치면 그것마저 깨졌다.
실측 전량은 audit/type-store-index-keyof/(측정값은 유효, 결론만 철회됨).
⛔ H-75 · H-76 — 무효. WrapStore/ProcessStoreType이 폐기된다
두 발견은 전부 *"정본 Source<T> 선언을 type function 안에서 어떻게
합성하는가"*에 대한 것인데, 위 재설계로 합성 자체를 안 한다. 남는 타입
함수는 CheckReserved 하나뿐이고 그건 T를 검증만 하고 그대로 통과
시키므로 (a) 바깥 별칭을 반환할 일이 없고(H-76의 근거), (b) 메소드 self
파라미터 불변성 문제도 없고(H-76), (c) ②쪼개기를 type function 안에서
할 일도 없다(H-75). typing-limits.md §5의 "✅ 검증 완료" 서술과
store-plan.md의 WrapStore 스케치는 삭제 대상이다.
⛔ H-74 — 무효 (근거는 lazy __index 폐기)
원문은 *"eager defaults 경로가 __index를 통째로 우회하므로 '고정 메소드
테이블을 먼저 확인'이라는 예약 키 방어가 성립하지 않는다"*였다. 명시적
초기화가 확정되며 lazy __index 폴백 자체가 없어졌다 — 우회할 방어가
아예 없으므로 발견이 소멸한다. 지금 예약 키(Of/Names) 방어는
__index가 아니라 **타입 레벨 CheckReserved**가 한다.
[2026-08-25 문구 정정] 한때 여기 근거로 "그림자 백킹 테이블"을 들었는데,
그건 같은 날 철회된 재설계의 산물이다
(archive/store-value-field-redesign-withdrawn.md). 판정(무효)은 안 바뀐다.
H-83 — table.clone(defaults or {}) (확정)
한 글자. 명시적 초기화와 모순되지 않는다 — 선언된 키가 하나도 없는
Store<<{}>>()는 여전히 유효하고(줄 키가 없으니 넘길 것도 없다), 그때
table.clone(nil)이 table expected, got nil로 죽는 걸 막는다.
"선언한 키는 값을 준다"와 "키가 0개면 인자도 없다"는 서로 다른 층위다.
H-79 — store:Names() 신설 (확정)
갈래 (c). Tag:Names()/attr:NameMap()과 같은 계열이고, 동적 키 표면
(store:Of<<T>>(name))과 같은 자리에 둔다(사용자: "이것도 GetDynamic
처럼 둡니다" — 그 GetDynamic이 지금의 Of다). 그룹 Attribute가
요구하던 열거가 이걸로 닫힌다.
시그니처는 Names: (self: any) -> { string }이다. [2026-08-25 문구
정정] 한때 여기 { keyof<index<Self,"__store">> }라 적었는데 그건 같은 날
철회된 재설계의 타입이다. 런타임 성립 근거도 바뀌었다 — 팬텀 필드가
아니라 명시적 초기화다: defaults가 곧 선언 키 집합이므로 그림자
테이블의 키를 그대로 준다. (감사 4패스가 잡은 것 — 철회 전 모양에선
Luau가 타입 인자를 런타임에 지워 Names()가 구현 불가능했다.)
H-95 — 두 콜백 시그니처를 실측 통과 형태로 고친다 (확정)
갈래 (a).
Effect의fn— 가변 반환 팩-> ...(() -> ()). 단일 옵셔널 반환에 맞고,function(self) end와 cleanup 반환이 둘 다 통과한다.:List의updateFn— 함수 타입의 유니온Fn2 | Fn1 | Fn0. 네 모양 (2개/1개/nil/없음)이 전부 통과하고 엉뚱한 타입은 여전히 잡힌다(음성 대조군 확인).
"항상 명시적으로 반환하라"를 계약으로 두는 안은 기각 — useEffect 동형이라는
확정 서술과 인체공학이 어긋나고, --!nocheck 코드에선 조용히 지나간다.
H-94 — __call이 아니라 지정된 필드로 받는다 (확정, 필드 이름 미정)
사용자 판단: "그런데 이러면, Blocker 자체도 처음에 슈거로 두지 못했었던
이유가 해결됩니다. 아에 어플리케이티브 펑터로써, __call 이 아닌 다른
필드로 들어가는게 맞아보여요. 외부에서 직접 () 호출하는건 의미 없게
둬야해요."
- 원문의 갈래 (a)(함수와 콜러블의 유니온)는 기각 — 유니온도 캐스트도 필요 없어진다.
:Apply는 "함수" 또는 "그 필드를 가진 객체"를 받고,Debounce{...}/Throttle{...}/Blocker가 전부 같은 계약을 만족한다.- 실측 뒷받침:
test.luau가__call경로가 죽었음을 확인했다 — 타입 레벨__call은self를 못 받고(mock()이 인자만 남는다),typeof(getter2<<T>>)로T를 넘기는 것도 실패한다. - 남은 것: 그 필드의 이름과 정확한 시그니처(구현 시 정하면 됨).
🅒 예외 안전성 — H-88 · H-89 · H-87 · H-103
네 자리 전부 UB로 명문화, pcall로 감싸지 않는다 (확정)
2026-08-21에 slot-plan.md의 materializeSlotTree에 대해 내린 판단
("마운트 도중 예외는 quad가 복구를 보장하지 않는 상태이고 … 아직 실제로
밟은 적 없는 경로다 — conventions.md의 "드문 오용이나 가상의 미래
요구까지" 절이 세운 원칙 그대로. 실제로 물리면 그때 넣는다")를 전
자리로 확장한다. 🅔의 Rerun에 대해 같은 원칙을 이미 확정했다
("에러가 난 이후 데이터의 무결이 깨져도 별 책임 안 진다는 quad의 일반
동작").
- 전파 루프(
H-88, 구독자당 hot path), 게이트 flush +Blocker:Off()순회(H-89), 배치 게이팅(H-87), Dispatch 체인 슬롯(H-103, 자리당 hot path) — 넷 다 감싸지 않는다. - 문서에 없던 것을 적는 게 이 항목의 산출물이다 — 지금
state-epoch-plan.md/gate-plan.md/blocker-plan.md셋엔error라는 단어가 0건이다. yield 금지와 같은 톤으로 "예외가 나면 그 파동/그 자리의 부기는 복구되지 않는다"를 명시한다. H-87의 심각도 정정: 🔴 → 🟡. 원문의 *"코퍼스가 error 경로를 어느 쪽도 다루지 않는다"*는 거짓이었다(위 선례가 있다). 새 결함 보고가 아니라 기존 판단의 적용 범위 문제였다.H-88의 부정 주장 정정: *"예외 안전성 계약이 코퍼스에 한 줄도 없다"*는 과했다 — 위 선례가 유일한 선례이고, 그 선례가 고른 방향이 바로 지금 채택한 쪽이다.
H-89의 나머지 — :Sync(batch)는 전파 전 (확정, 물을 것 없음)
gate-plan.md 4번이 *"실제로 전파할 때"*라고만 하고 앞/뒤를 안 정했는데,
참조 구현의 GateNode:_flush가 이미 빈 배치 얼리리턴 → 스왑 → Sync →
전파 순서이고 그게 4·8번과 일치한다. 그 순서를 문서에 명시한다.
🅙 error 계약 — H-104 · H-105
⚠️ 두 항목의 수치를 먼저 고칠 것 — 원문의 "42곳"은 산문 언급까지 센 것이다. 실제 quad 자신의 error 코드 자리는 약 29곳, 한국어는 17이 아니라 약 23, 영어는 6(동적 경로 가드 4형제 + 모듈 초기화 + attribute 이름 충돌)이 맞다.
H-104 — 사용자 입력 검증은 level 2, 내부 불변식은 level 1 (확정)
error("Effect: dep #3 is not a State/Source/Ref", 2) -- 호출부를 가리킴
error("Dispatch: bookkeeping is broken (bk.N=" .. n .. ")", 1) -- 그 자리를 가리킴
즉시 error가 quad의 주 방어선인데 지금은 전부 quad 내부 줄을 가리켜 사용자가 자기 코드 어디서 틀렸는지를 못 본다. 이분은 명확하다 — 사용자가 잘못 넘긴 것(deps 타입, 이중 바인드, 예약 키…)은 호출부를, quad 자신의 부기가 깨진 것은 그 자리를 가리킨다. 29곳을 쓰기 전에 정해야 한 번에 맞는다.
H-105 — error 메시지는 영어로 통일 (확정)
사용자 확정. conventions.md의 *"사용자가 보게 될 것은 한국어"*는
이 프로젝트의 대화·문서 규칙이고 quad 라이브러리 사용자에게까지
적용된다고 정해진 적이 없다 — 이번에 정한다. 이미 영어인 6곳(동적 경로
가드 4형제 등)이 핵심 경로라는 점도 같은 방향이다. 기존 한국어 메시지
약 23곳은 구현 시 영어로 쓴다(문서의 예시 메시지도 같이).
그 밖의 개별 확정
H-68 — Source:Set(v)는 동일값이어도 항상 갱신하고 emit (확정)
판정이 값 동등성이 아니라 리비전이라는 Epoch 모델과 일관된다. 더
중요한 건 테이블 값이다 — mutate한 뒤 같은 테이블을 다시 Set하는
것이 ==로 dedup되면 변경이 조용히 증발한다. dedup은 이미 하류
(EpochMap, 게이트)에서 한다.
🅚 ⭐ "중간 State GC" 미해결이 닫혔다 — H-93 · H-98
사용자 확정: "단순히, 각 state 들이 상위 State|Source 를 홀드하는
_hold 를 놓는것으로 바로 해결된다. 당연히 후행은 선행 요소들이
있어야하기 때문. 선행 state 가 후행 state 를 얻으려 하는건 UB이므로 가능한
일이다.(이건 릴레이션도 아니라 gc되긴 하지만.)"
불변식이 이걸로 명문화된다:
| 방향 | 강도 |
|---|---|
하류 State → 상류 State/Source (_hold) |
강함 |
| 상류 → 하류 (구독자 집합) | weak-키 (🅑-1에서 확정) |
- 체인은 말단(Observer/Effect/leaf)이 살아 있는 동안 통째로 살아 있고,
말단이 죽으면 통째로 수거된다.
Relate가 아니므로 순환도 안 만든다. H-93소멸 — 루트Epoch(Source)가 하류보다 먼저 수거될 수 없으므로 *":Refresh()가false를 줘 낡은 값을 최신이라고 확신한다"*는 경로가 생기지 않는다.H-98소멸 —source-state-plan.md의 두 문장("참조를 아무 데도 안 담아도 정상", "GC되지 않고 영원히 계속 실행됨")이 서로 모순 없이 성립한다.:Subscribe()가 전역 강 레지스트리에 핸들을 넣고, 핸들이_hold로 상류를 잡는다.- 🅑-2(eager 등록)의 캐비엇도 해소 — 그 절에 적어둔 *"eager이므로 중간 State GC 미해결이 더 절실해진다"*는 이제 해당 없음.
question.md최우선 절의 첫 항목이 닫힌다(실측 스파이크는 여전히luau-test에 하나 두는 게 좋다 — 그 파일은 "상류 strong / 하류 weak 불변식"을 음성 대조군까지 확인하는 형태로).
🅛 저장소·로드맵 — H-78 · H-97 · H-80 · H-81 · H-99
⭐ H-78 — 근본 원인은 luau CLI가 심볼릭 링크를 못 탄다는 것 (확정)
원문은 *"안 돈다"*까지만 짚었는데, 이번에 최소 재현으로 원인을 특정했다:
require("./real") → OK (진짜 디렉토리 + init.luau)
require("./linked") → 실패 (같은 디렉토리를 가리키는 심볼릭 링크)
could not resolve child component "linked"
진짜 디렉토리 + init.luau 파일 심볼릭 → 실패 (no module present at resolved path)
파일 심볼릭 직접 require → 실패
pesde의 워크스페이스 링크가 전부 디렉토리 심볼릭
(.pesde/…/quad_types/src -> quad-types/src)이라, quad-base/src/init.luau의
require("./roblox_packages/quad_types")가 그 링크에 닿는 순간 죽는다.
luau 0.735(현재 최신)에 관련 옵션도 없다.
거짓 클린도 재현했다 — 같은 경로를 luau-analyze에 걸면 진단이 lint
둘뿐이고 local x: number = m.ok(실제 boolean)를 안 잡는다. 모듈을
any로 떨어뜨리고 조용히 통과한다.
해법(확정): 개발용 리링크 스크립트 — .pesde 아래 디렉토리
심볼릭을 실제 복사로 교체하고, 테스트/분석 스크립트가 그걸 먼저 돌린다.
pesde 설정도 게시 경로도 안 건드린다. 워크스페이스 멤버를 고치면 다시
돌려야 하므로 테스트 스크립트가 매번 선행 실행한다.
같이 고칠 것: ROADMAP.md:198의 "전부 PASS" — 날짜도 전제조건도 없다.
지금 실제로는 smoke.mock만 PASS이고 smoke.init/smoke.plugin은 리링크
없이는 안 돈다. 스파이크 23도 같은 이유로 의도한 음성 대조군이 안 뜬다
(luau-test/STATUS.md에 반영).
H-97 — mock에 생명주기 4종을 M2에서 최소 구현 (확정)
bindLifetime/unbindLifetime/canBound/canExecute를 mock 백엔드용으로
가장 단순하게 구현한다(커밋된 mock에 signal/Connection이 이미 있다).
M2의 핵심이 전파 루프인데 그걸 한 번도 안 돌려보고 M3로 넘어가는 것이
이번 라운드가 찾은 종류의 결함을 그대로 낳는다.
H-80 · H-81 · H-99 — 체크박스·소스 트리 갱신 (결정 불필요)
H-80:ROADMAP.mdM2의Quad추가 목록이Source/State/Store뿐 —Effect/is*전량/bindLifetime4종/Blocker/Relate를 더한다.State는 런타임 생성자가 없다는 것도 명시(파생으로만 생긴다).H-81:isModifier런타임 가드 체크박스가 M7에만 있다 — 적용 지점이 전부 M2 파일(Source:Set/Store 생성/:Compute캐싱)이므로 M2 체크리스트에 넣고,modifier-plan.md7번과source-state-plan.md의 적용 지점 목록을 일치시킨다(독립Source(someModifier)가 한쪽에만 있다).H-99:architecture.md소스 트리에Observer.luau자리와:Subscribe()전역 레지스트리의 소유 모듈을 명시(EpochMap.luau가 *"State.luau에 묻지 않고 별도 모듈"*로 명시된 것과 대비를 맞춘다).
🅜 문서 정합 — 결정 불필요 (H-66 · H-67 · H-82 · H-91)
H-66:typing-limits.md영향 범위 표의state:Observer(fn)행을EffectHandle 반환→Observer반환.H-67:gate-plan.md4번이OffWithoutEmit비우기의 근거로 드는 용례가 gated state를 안 쓴다 — gated state를 쓰는 용례로 교체.H-82::With를 실노드로 확정한 근거 2번("w의 캐시를 c1/c2가 공유")이 pass-through 노드엔 성립하지 않는다 — **"엣지 수와 에포크 부기"**로 교체. 결론은 안 바뀐다(근거 1·3이 유효).H-91:state-epoch-plan.md§8의 *"항상 state 는 get 이 최신"*을 **"선언한 의존성에 대해서는 항상 최신"**으로 좁히고,Animate가 미선언 읽기를 설계로 쓰는 의도적 예외임을 가리킨다.
나머지 개별 — 구현 시 정하면 되는 것
H-69통과 모드 게이트가 emit마다 weak 테이블을 하나씩 할당 — 정확성 문제 아님. 전파 루프를 코드로 쓸 때(🅑) 같은 자리에서 정한다.H-92구독자 스냅샷이 emit마다·노드마다 배열 하나를 할당 —state-epoch-plan.md§2가 테이블 리비전을 기각한 GC 근거, §7 비용 절과 어긋난다. 정확성 문제 아님, 서술만 실제와 맞춘다.H-84:With(...)/state:Block(b)/Source:Emit()이ROADMAP.mdM2 체크리스트에 개별 항목으로 없다 — 추가.H-96trailing deps가 붙으면 콜백 파라미터 무주석 추론이 깨진다 — 결정이 아니라typing-limits.md§7에 경계 한 줄("단 trailing deps가 붙으면 dep 파라미터엔 주석이 필요하다").H-100{[Source<T>]: true}가{[Epoch]: true}자리에 안 들어간다 (인덱서 키 불변) —EpochSet리터럴에 캐스트가 필요하다는 것만 적는다.H-106getOffsetAt의contribution(bk, i)에nil가드가 없어C-6이 승격한 진단이 우회된다 — 한 줄 가드 추가.
반영 후 검증 — /code-review high 1회 + 감사 다패스 (2026-08-25)
base/ 반영이 끝난 뒤 각도를 바꿔가며 검증을 돌렸다. 사용자 지침:
"여러 각으로 넣으면 다양한 문제들이 해결되거든", "수렴 되기 까지 여러
각으로 한번씩 넣어봐줘. 그게 stale 부채를 막는 길이라서."
각 패스가 낸 발견이 서로 거의 안 겹쳤다 — 이게 이 절을 남기는 이유다. 다음에 큰 반영을 할 때도 한 각도로 여러 번이 아니라 각도를 바꿔가며 돌릴 것.
| 패스 | 각도 | 발견 | 대표적으로 잡은 것 |
|---|---|---|---|
/code-review high |
diff 자체의 결함(사용자만 호출 가능) | 12 | 캐시 카운터 시드 충돌(갱신이 감소라 첫 무효화에서 두 값이 같아짐), _cleanup == nil로 "설치됨"을 판정해 H-58 부활, 되감기 +1이 바뀐 길이를 sum에서 누락, bk.indexOf(len)가 길이 값을 키로 써서 두 자리가 접힘, relink.sh 재실행 no-op |
| 감사 1차 | diff 정합성 — 폐기된 이름·인덱스 레이어·색인 행 | 12 | ROADMAP.md M2 전파 루프 체크리스트가 H-56이 뒤집은 것을 그대로 들고 있었다(구현자가 보는 자리), CLAUDE.md/project-context.md가 닫힌 게이트를 열린 것으로 서술(매 세션 로드됨) |
| 감사 2차 | 새 의사코드를 손으로 실행 | 6 | lifecycle-pattern.md의 bindLifetime이 폐기된 _observers cascade를 정본 의사코드로 들고 있었다, ref-plan.md가 같은 파일 안에서 :Uncallback과 :WeakCallback 두 계약을 동시 주장, gate flush 스니펫이 4단계 중 2단계만 |
| 감사 3차 | 소비자 관점(M2 표면을 쓰는 쪽) | 6 | Store 재설계가 소비자 예시 12개 파일에 전혀 안 퍼짐 — 특히 component-composition-plan.md §3이 배너 없이 옛 모델을 "확정"으로 서술 |
| 감사 4차 | 구현자 관점(M2를 위에서부터 짜 내려간다면) | 4 | store:Names()가 런타임에 구현 불가능(Luau가 타입 인자를 지움) — 이게 오전 Store 재설계를 철회하게 만든 발단이다. 그 외 Effect↔Blocker 체크박스 순서, Observer.luau 파일명 누락, GateNode 조립 의사코드 부재 |
| 감사 5차 | 철회가 완전한가 | 13 | architecture.md를 철회에서 통째로 놓쳤다(두 자리, 그중 하나는 같은 파일 안에서 모순), source-state-plan.md가 한 파일 안에서 대입 부활/폐기를 동시 주장, GetDynamic→Of 개명이 5곳 누락(ROADMAP 체크박스 제목 포함), pre-implementation-audit.md의 "이미 고침" 목록이 철회된 고침을 완료로 기록 |
| 감사 6차 | 최종형 수렴 + H-74/H-79/H-83 재판정 |
3 | effect-plan.md가 같은 문서 안에서 폐기된 _installing을 "확정"으로 서술, README.md의 effect-plan 색인 행이 하루 전 상태, followup의 H-74/H-79 판정 문구가 철회된 모델을 근거로 인용. base/는 전부 최종형으로 일관 — 잔재가 인덱스·기록 레이어에만 남았다 |
교훈 둘 — (1) 각도를 바꾸면 계속 나온다. 여섯 패스가 12/12/6/6/4/13/3건을
냈고 겹치는 게 거의 없었다. 한 각도로 여러 번 돌리는 것보다 각도를 바꾸는
쪽이 압도적으로 낫다. (2) 하루 안에 두 번 바뀐 것은 인덱스·기록 레이어에
잔재가 남는다. 6차에서 base/는 이미 깨끗했는데 README.md 색인 행과
followup 판정 문구가 중간형을 들고 있었다 — "결정을 적는 곳"과 "결정을
가리키는 곳"이 다르기 때문이다.
교훈 셋 — 같은 결정을 여러 문서가 각기 다른 역할로 들고 있으면
한 곳만 고치기 쉽다. H-56(전파 루프)은 lifecycle-pattern.md(산문) /
source-state-plan.md(의사코드) / ROADMAP.md(구현 체크리스트) 셋에
흩어져 있었고, 첫 반영은 앞의 둘만 고쳤다 — 구현자가 실제로 보는 건
세 번째다.