quad/.claude/qa-request/pre-implementation-handtrace-round7-followup.md
qwreey eb321e1747
qa: 7라운드 발견 52건 처리·반영 — Store 재설계 시도와 철회, 타입 함수 원칙 신설
`qa-request/pre-implementation-handtrace-round7.md`의 `H-55`~`H-106`을
사용자와 대화형으로 처리하고 `base/`에 전량 반영했다. 결정의 소스는
`-followup.md`(개수·개별 항목은 여기서 세지 않는다).

## 처분

확정 39 / 무효·소멸 4(`H-73`~`H-76`) / 기각 1(`H-77`) / 범위 축소 2 /
다른 항목으로 흡수 6.

**부수로 `question.md` 최우선 절이 비었다** — 중간 State GC는 `_hold`
불변식(하류 → 상류 강함)으로, 동적 키 표면 위치는 `store:Of<<T>>(name)`
하나로 닫혔다. **M2 착수를 막는 항목이 없다.**

## 구조가 바뀐 것

- `Ref`가 `Epoch`를 만족(`.Revision` + `EpochBrand`) — 포탈 캐치업 비대칭과
  같은 `Ref` 중복 dep이 같이 닫힘
- `Weak*` 등록 표면 신설(`Ref:WeakCallback` / `Observer:WeakSubscribe`) —
  Weak 쪽이 프리미티브고 강한 쪽이 "GC 킵"을 얹은 것
- `Effect`: dep 등록이 생성자 한 곳으로, 강한 주인은 `_deps` 하나,
  억제는 사적 `Blocker`, `bindLifetime`은 핸들 하나에만 적용.
  `:Rerun()` 정의 신설(재진입은 지연 재실행), `_installed` 신설
- 전파 루프 의사코드 확정 — 구독자 집합의 원소는 Observer **값**이고
  **자식 State 노드는 `canExecute`를 안 탄다**(그대로 짜면 파생 State
  아래가 전부 침묵했다)
- `rawInvalid` → `cacheTargetCount`/`cacheCurrCount` 카운터 쌍
- `recompute` 재진입 차단 + `invalidAfter` 되감기, `gatedRecompute`가
  인덱스를 캡처하지 않고 토큰으로 조회
- `emit(commit) -> boolean`, `EpochMap:Peek` — 정책이 흡수 집합을
  버리지도 읽지도 못하던 것을 닫음
- error 계약(`level` 이분, 메시지는 영어)과 예외 계약(`pcall`로 안 감쌈)을
  `architecture.md`에 신설

## Store — 같은 날 재설계하고 철회했다

`H-75`/`H-76`으로 `WrapStore`/`ProcessStoreType`이 폐기되자 그 자리를
"`store.key`는 값, `store:Of(k)`가 프리미티브"로 채웠다가 **같은 날
철회**했다(`archive/store-value-field-redesign-withdrawn.md`). 살아남은 건
**명시적 초기화** 하나다. 최종형은 타입 인자에 `Source<T>`를 직접 쓰고
`store.key`는 평범한 레코드 필드이며 **타입 함수를 안 쓴다**.

철회 이유 중 하나가 원칙으로 승격됐다 — **"타입 함수는 타입이 못 잡는
문제를 에러로 띄우는 정도 이상으로 가지 않는다"**(`typing-limits.md` §0).
`index<>`/`keyof<>`도 Luau가 predefine한 타입 함수라 같은 함정을 갖는다.

## 툴체인

**`luau` CLI가 심볼릭 링크를 못 탄다**(디렉토리·파일 둘 다)는 것이 최소
재현으로 밝혀졌다 — pesde 워크스페이스 링크가 전부 심볼릭이라 스모크 2개가
안 돌았고 `luau-analyze`는 **조용히 통과**했다("거짓 클린"). `scripts/relink.sh`
+ `scripts/test.sh` 신설, 이제 스모크 셋 전부 PASS.

## 검증

`/code-review high` 2회(12건 + 14건)와 감사 8패스(12/6/6/4/13/3/6/0건)를
각도를 바꿔가며 돌렸고 전부 반영했다 — 마지막 패스가 무발견으로 수렴.
각 패스의 각도와 대표 발견은 `-followup.md`의 검증 절이 소스.
`doc-check.py` ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>

Claude-Session: https://claude.ai/code/session_012oLwATeQdq9TCFdENPutFG
2026-08-25 23:40:19 +09:00

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 우선 모델 역전: store.key = value 부활(archive/store-value-field-redesign-withdrawn.md) 툴체인: scripts/relink.sh + scripts/test.sh 신설 — luau CLI가 심볼릭 링크를 못 탄다는 것이 최소 재현으로 밝혀졌다(H-78).


🅐 게이트 정책의 상태 접근 통로 — H-55 · H-86 · H-72 · H-63

H-55 + H-86emit에 인자와 반환값을 준다 (확정)

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 시그니처는 안 바뀐다"*는 인자 목록은 유지한 채 최소로만 되짚는다(인자가 늘지 않고 기존 인자에 선택 파라미터와 반환값이 붙는다).

ThrottleonWindowEnd가 이걸로 닫힌다:

if not emit() then   -- 보류분 없었음
  window = nil       -- 완전 idle 복귀 → 타이머 체인 종료
else
  rearm()
end

H-72EpochMap:Peek(from) -> boolean 추가 (확정)

갈래 (a). 읽기 전용 비교(저장된 리비전과 비교만 하고 덮지 않음). Update가 이미 {읽기, 비교, 쓰기}Peek은 그 앞 두 개만 쓰는 것이고 내부 코드 공유가 쉽다. GateNode:_receive가 이걸 쓴다:

local valueChanged = self.valueEpochMap:Update(from)
local emitChanged  = self.emitEpochMap:Peek(from)   -- 갱신 안 함

H-63 — 세 자리 모두 선례대로 (확정)

  1. onunblock 핸들 보관은 weak-키 해시맵 셋 {[handle] = true} (__mode = "k") — H-7Ref.Callbacks에 한 것과 동일. 배열의 구멍/ipairs 조기 종료 문제가 소멸한다.
  2. 강한 주인은 정책이 반환하는 onUpstreamEmit 클로저다 — 그 클로저가 onunblock 핸들을 upvalue로 잡는다. 체인은 GateNode → onUpstreamEmit → onunblock 핸들이고, Blocker 쪽은 weak이라 게이트 노드가 죽을 때만 수거된다. Blocker가 핸들을 강하게 드는 안은 기각 — 오래 사는 Blocker 하나가 거기 걸렸던 모든 gated state와 상류 체인을 영원히 살려두므로(:List 항목마다 게이트를 무는 패턴에서 직행 누수).
  3. 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

이 묶음은 대화 중에 원문의 갈래 어느 것도 아닌 구조로 수렴했다. 사용자가 두 도구를 제안했고(RefEpoch 승격, 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/unbindLifetimeEffect 핸들 하나에만 적용되고, 내부 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는 "RefEffect를 붙든다"만 풀고 "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(중복) — RefEpoch로 승격 (확정)

사용자 제안: "혹은 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-60Rerun() 정의: 재진입은 지연 재실행 (확정)

사용자 판단: "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 검증 (확정)

  • nil dep → 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에서 같은 패턴을 권해왔다.
    • 부수로 *"이팩트를 여러곳에 바인딩하면?"*도 자연히 해결된다(매번 새 인스턴스).

이 묶음에서 안 만들기로 한 것

WeakRef, Effect:Clone(), Effect<UD>:Userdata() / SetUserdata· GetUserdata, Effect.Template. 전부 검토 후 기각 — 위 각 절이 근거의 소스다.


🅓 "긴 연산의 꼬리가 도중 변경을 덮어쓴다" — H-85 · H-101 (+ H-102)

H-85rawInvalid 불린을 캐시 카운터 쌍으로 교체 (확정)

원문의 갈래 (a)(rawInvalid = falsefn 앞으로)는 불충분하다사용자 지적: "이러면 대신, 캐시가 언제 생성된 캐시인지 모르는 이슈가 발생하지 않아? 특히 에러가 난다고 하면, 다시 계산 안하고 이전 결과를 다시 쓰겠네?" 그리고 "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로 되감는다 (확정)

먼저 원문의 두 서술을 정정한다.

  1. ":ListupdateFnoffset을 인자로 받는 사용자 코드라 거기서 형제 Slot을 조작"틀렸다. 사용자 정정: "updateFn 자체를 재실행 하는건 아니거든. offset 을 state 로 넘겨주어서 옵져빙 하게 만들잖아." 남는 트리거는 그보다 좁은 것 — slot.Offset State를 관측하는 사용자 코드.
  2. "바깥 루프의 남은 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 은 여전히 유효하다는거죠."getOffsetAtfor 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.processObserverEffectLeafHandler.process.

같이 할 것: luau-test/done/07에 되참조 케이스를 음성 대조군으로 추가한다 — 지금 그 파일은 "GC-native 아키텍처의 핵심 전제를 검증했다"고 여러 문서에 인용되는데 실제로는 안전한 모양만 봤다.

H-77RunInit 사례는 기각, Relate 규칙만 명문화 (확정)

사용자 정정: "그런 경우 자체가 날 수 없음 … initFn 은 모듈을 인자로 받음. 그건 클로저 캡쳐가 아님. 게다가 initFn 자체가 항상 하나야. 그래야지 재진입이 방어되거든. 그 의미는, initFn 자체는 프로토에 불과하다는거임. 게다가, 그 안에서 만들어낸 함수들이 module 을 레퍼런싱 해도, 리턴 값이 quad자신에 flatten 되어 뮤테이션 되어 들어가는 구조 상, 그냥 module.fn 수행하는거랑 다른게 없음."

  • 원문의 재현 코드(q:RunInit(function() q.tagB = true end))는 성립하는 사용법이 아니다 — 인라인 클로저는 매번 새 identity라 멱등 가드 자체가 무의미해진다. 발견이 자기 전제를 깨뜨린 경우.

  • quad-base/src/init.luaurunInitRelate 설계는 그대로 둔다(최상위 Relate, quad 인스턴스가 바깥 키, initFn이 내부 키). :23 주석도 그대로.

  • 다만 Relate의 슬롯별 강/약 규칙 자체는 문서에 없으므로, H-71이 어차피 다시 쓰기로 한 "위험한 패턴" 절에 세 슬롯 표를 넣는다:

    슬롯 SetStrong SetWeak
    바깥 키(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.mdWrapStore 스케치는 삭제 대상이다.

H-74무효 (근거는 lazy __index 폐기)

원문은 *"eager defaults 경로가 __index를 통째로 우회하므로 '고정 메소드 테이블을 먼저 확인'이라는 예약 키 방어가 성립하지 않는다"*였다. 명시적 초기화가 확정되며 lazy __index 폴백 자체가 없어졌다 — 우회할 방어가 아예 없으므로 발견이 소멸한다. 지금 예약 키(Of/Names) 방어는 __index가 아니라 **타입 레벨 CheckReserved**가 한다. [2026-08-25 문구 정정] 한때 여기 근거로 "그림자 백킹 테이블"을 들었는데, 그건 같은 날 철회된 재설계의 산물이다 (archive/store-value-field-redesign-withdrawn.md). 판정(무효)은 안 바뀐다.

H-83table.clone(defaults or {}) (확정)

한 글자. 명시적 초기화와 모순되지 않는다 — 선언된 키가 하나도 없는 Store<<{}>>()는 여전히 유효하고(줄 키가 없으니 넘길 것도 없다), 그때 table.clone(nil)table expected, got nil로 죽는 걸 막는다. "선언한 키는 값을 준다"와 "키가 0개면 인자도 없다"는 서로 다른 층위다.

H-79store: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).

  • Effectfn — 가변 반환 팩 -> ...(() -> ()). 단일 옵셔널 반환에 맞고, function(self) end와 cleanup 반환이 둘 다 통과한다.
  • :ListupdateFn — 함수 타입의 유니온 Fn2 | Fn1 | Fn0. 네 모양 (2개/1개/nil/없음)이 전부 통과하고 엉뚱한 타입은 여전히 잡힌다(음성 대조군 확인).

"항상 명시적으로 반환하라"를 계약으로 두는 안은 기각 — useEffect 동형이라는 확정 서술과 인체공학이 어긋나고, --!nocheck 코드에선 조용히 지나간다.

H-94__call이 아니라 지정된 필드로 받는다 (확정, 필드 이름 미정)

사용자 판단: "그런데 이러면, Blocker 자체도 처음에 슈거로 두지 못했었던 이유가 해결됩니다. 아에 어플리케이티브 펑터로써, __call 이 아닌 다른 필드로 들어가는게 맞아보여요. 외부에서 직접 () 호출하는건 의미 없게 둬야해요."

  • 원문의 갈래 (a)(함수와 콜러블의 유니온)는 기각 — 유니온도 캐스트도 필요 없어진다.
  • :Apply는 "함수" 또는 "그 필드를 가진 객체"를 받고, Debounce{...} / Throttle{...} / Blocker가 전부 같은 계약을 만족한다.
  • 실측 뒷받침: test.luau__call 경로가 죽었음을 확인했다 — 타입 레벨 __callself를 못 받고(mock()이 인자만 남는다), typeof(getter2<<T>>)T를 넘기는 것도 실패한다.
  • 남은 것: 그 필드의 이름과 정확한 시그니처(구현 시 정하면 됨).

🅒 예외 안전성 — H-88 · H-89 · H-87 · H-103

네 자리 전부 UB로 명문화, pcall로 감싸지 않는다 (확정)

2026-08-21에 slot-plan.mdmaterializeSlotTree에 대해 내린 판단 ("마운트 도중 예외는 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-68Source: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.luaurequire("./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.md M2의 Quad 추가 목록이 Source/State/Store뿐 — Effect/is* 전량/bindLifetime 4종/Blocker/Relate를 더한다. State는 런타임 생성자가 없다는 것도 명시(파생으로만 생긴다).
  • H-81: isModifier 런타임 가드 체크박스가 M7에만 있다 — 적용 지점이 전부 M2 파일(Source:Set/Store 생성/:Compute 캐싱)이므로 M2 체크리스트에 넣고, modifier-plan.md 7번과 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.md 4번이 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.md M2 체크리스트에 개별 항목으로 없다 — 추가.
  • H-96 trailing deps가 붙으면 콜백 파라미터 무주석 추론이 깨진다 — 결정이 아니라 typing-limits.md §7에 경계 한 줄("단 trailing deps가 붙으면 dep 파라미터엔 주석이 필요하다").
  • H-100 {[Source<T>]: true}{[Epoch]: true} 자리에 안 들어간다 (인덱서 키 불변) — EpochSet 리터럴에 캐스트가 필요하다는 것만 적는다.
  • H-106 getOffsetAtcontribution(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.mdbindLifetime이 폐기된 _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 재설계를 철회하게 만든 발단이다. 그 외 EffectBlocker 체크박스 순서, Observer.luau 파일명 누락, GateNode 조립 의사코드 부재
감사 5차 철회가 완전한가 13 architecture.md를 철회에서 통째로 놓쳤다(두 자리, 그중 하나는 같은 파일 안에서 모순), source-state-plan.md가 한 파일 안에서 대입 부활/폐기를 동시 주장, GetDynamicOf 개명이 5곳 누락(ROADMAP 체크박스 제목 포함), pre-implementation-audit.md"이미 고침" 목록이 철회된 고침을 완료로 기록
감사 6차 최종형 수렴 + H-74/H-79/H-83 재판정 3 effect-plan.md같은 문서 안에서 폐기된 _installing을 "확정"으로 서술, README.mdeffect-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(구현 체크리스트) 셋에 흩어져 있었고, 첫 반영은 앞의 둘만 고쳤다 — 구현자가 실제로 보는 건 세 번째다.