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
This commit is contained in:
qwreey 2026-08-25 23:40:19 +09:00
parent 8cc388cfab
commit eb321e1747
Signed by: qwreey
GPG key ID: D28DB79297A214BD
52 changed files with 3837 additions and 450 deletions

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,355 @@
# [검토 후 철회] Store를 "값 필드 + 타입 함수 합성"으로 바꾸려던 시도
**상태**: archive — **2026-08-25 오전에 도입했다가 같은 날 철회됨.**
지금 유효한 설계는 `base/store-plan.md`가 소스. 이 문서는 그 시도의 원문과
**왜 되돌렸는지**를 보존한다(히스토리 전용, **여기 적힌 것을 현재 설계로
읽지 말 것**).
## 무엇을 시도했나
`WrapStore`/`ProcessStoreType` 타입 함수로 결과 타입을 **합성**하는 옛
접근이 7라운드 `H-75`/`H-76`으로 무너지자, 그 자리를 다음으로 채우려 했다:
| 시도한 것 | 지금(철회 후) |
|---|---|
| 타입 인자가 **평범한 값 타입**(`Store<<{hp: number}>>`) | 타입 인자에 **`Source<T>`를 직접**(`Store<<{hp: Source<number>}>>`) |
| `store.key`**값**(`__index`가 `Of(k):Get()`) | `store.key`**`Source<T>`**(평범한 레코드 필드) |
| `store.key = v` **부활**(`__newindex`) | **폐기 유지**`store.key:Set(v)` |
| `store:Of(k)`**프리미티브** | `store:Of<<T>>(name)`**동적 키 전용**(옛 `GetDynamic` 흡수) |
| 팬텀 필드 `__store` + `index<>`/`keyof<>` | **타입 함수 안 씀** — 평범한 레코드 |
| 선언만 하고 값을 안 주는 키를 `?`로 표현 | **명시적 초기화**(이건 **살아남았다**) |
**살아남은 것은 명시적 초기화 하나**다 — eager/lazy 이중 모델 폐기,
`store:Names()` 신설, `WrapStore` 폐기. 나머지는 전부 되돌렸다.
## 왜 철회했나
**사용자 지적**(2026-08-25): *"우리가 너무 '필드셋' 으로 처리하고싶다에
너무 몰두해버린것 같네요. 필드의 read write 의 실질적 의미론이 어떤지도
정의하지 못해버린채요. 이러면, `.Value = 1` 이 정적 쓰기처럼 보일텐데,
여기서 error 터지는 trace 가 나오면 당황스럽기도 하고요, 약간 마법적
동작이기도 해요. … 왜 우리가 `Source()` 를 직접 넣는걸 거부하고, 이렇게
까지 하려 했죠? 단순히, `Store<{ a: Source }>` 로 두고, `store.a:Set,Get`
하지 말아야할 이유가 있을까요?"*
되짚어보니 **비용이 이득보다 컸다**:
1. **읽기/쓰기 의미론을 정의하지 못한 채로 문법만 도입했다.**
`store.key = v`가 정적 쓰기처럼 보이는데 실제로는 `Set`을 트리거하고,
실패하면 그 자리에서 error trace가 난다 — 2026-08-06이 대입 문법을
폐기한 논거 2번(*"`=`가 암시하는 즉시 커밋이 lazy와 안 맞는다"*)이
그대로 되살아난 셈이다.
2. **`index<>`/`keyof<>`도 타입 함수다.** **사용자 지적**: *"사실 keyof 와
index 도 타입함수입니다(구현을 뜯어보면, 루아우에서 프리디파이닝한
타입함수임). 타입함수가 가지는 고질적 문제를 그대로 가져요."*
실제로 `H-76`이 지적한 부류의 문제가 새로 하나 생겼다 — `Of`
**`Self` 제네릭을 거치면 콜백 파라미터 추론이 깨져** `s``unknown`으로
떨어졌다(평범한 레코드 필드에선 정상 추론된다, 실측 대조).
3. **`store:Names()`가 런타임에 구현 불가능했다.** Luau가 타입 인자를
런타임에 지우므로 `Store<<{hp: number, name: string?}>>({hp = 100})`에서
`name`이 선언됐다는 사실을 런타임이 알 방법이 없다. 그런데
`attr:NameMap()`은 선언된 전체 키를 요구한다 — 즉 `H-79`가 "닫혔다"고
한 문제가 옵셔널 미설정 키에 대해 그대로 재발한다(감사 4패스 발견).
4. **`?`(nilable) 선언을 떠받칠 sentinel이 없었다.** `None`은 **핸들러
배열의 nil hole** 때문에 생긴 물건이라 "값이 없다"가 아니고,
`Source<None|T|nil>`을 정의한 적도 없어 `Compute`가 뭘 받을지부터
안 정해진다(**사용자 분석**). 그렇다고 `nil``None`을 가르는 새
도구를 두기엔 의미론이 부딪힌다.
5. **표면이 늘었다**`Of` + `Names` + `GetDynamic` 셋이었는데, 철회 후엔
`Of``GetDynamic`을 흡수해 둘이 된다.
**여기서 나온 원칙 하나가 `base/typing-limits.md` §0으로 승격됐다** —
*"타입 함수는 타입이 못 잡는 문제를 **에러로 띄우는** 정도 이상으로 가지
않는다."* 이 시도가 정확히 그 선을 넘은 사례다.
경위 원문은 `qa-request/pre-implementation-handtrace-round7-followup.md`
🅖🅗 절, 발견 원문은 `qa-request/pre-implementation-handtrace-round7.md`
`H-73`~`H-76`.
---
## 철회된 시도의 원문 (보존)
아래는 **2026-08-25 오전에 `base/store-plan.md`에 실제로 들어갔던 네 절**을
그대로 옮긴 것이다. 같은 날 철회돼 지금은 `base/store-plan.md`에 없다.
**여기 적힌 것을 현재 설계로 읽지 말 것** — 특히 `store.key`가 값이라는
서술, `__index`/`__newindex` 슈가, 팬텀 필드 `__store`,
`index<>`/`keyof<>`, `?` nilable 선언, `store.key = v` 부활은 **전부 철회**됐다.
---
## Store = Source들의 이름 붙은 모음 (명시적 선언, 2026-08-25 재설계)
> **⭐⭐ [2026-08-25 재설계] 이 절은 통째로 다시 쓰였다.** 옛 모델
> (`store.key`가 `Source<T>`를 직접 반환하는 레코드 필드 + eager/lazy
> 이중 생성 + `WrapStore` 타입 함수)은 **역전**됐고 원문은
> 이 파일(당시 이름은 store-source-record-model-reversed.md였다)에 있다. 경위는
> `qa-request/pre-implementation-handtrace-round7-followup.md`.
**Store는 키를 타입 인자로 명시해서 만든다.** 타입 인자에 담는 것은
`Source<T>`가 아니라 **평범한 값 타입**이다.
```lua
local store = quad.Store<<{
hp: number,
name: string?, -- 안 넘겨도 되는 값은 `?`로 선언
}>>({ hp = 100 })
```
- **`store.key`는 값**이고 **`store.key = v`는 대입**이다 —
`__index`/`__newindex`가 아래 `Of`를 대신 불러준다. 읽기에 계산이 끼지
않는다(Store가 담는 건 `Source`뿐이므로 `Get`이 곧 현재 값이다).
사용자: *"`__index` 는 이제 `:Get` 해주어도 될것 같다. (연산이 안
일어난다. 진짜 현 값이 맞음)"*
- **`store:Of(key)`가 프리미티브다** — 그 키의 `Source`를 돌려준다.
`__index`/`__newindex`는 그 위의 슈가다(사용자: *"`__index` 를 통한
get 에선 `Of()` 결과에서 `:Get` 하도록 합시다. `__newindex`
유사하죠, 그 이후 `Set` 되는 식"*). 반응형으로 묶을 때는 이걸 쓴다.
```lua
store.hp -- number
store.hp = 5 -- 대입
store:Of("hp") -- Source<number>
store:Of("name") -- Source<string?>`?` 선언이 그대로 드러남
```
- **이름이 `Peek`이 아니라 `Of`인 이유** — 사용자: *"Peek 자체가, 애초에
여긴 다 확정된 값들의 무더기라서, Peek 아닌 다른게 좋긴 해보여요.
`Of` 가 가장 좋아보입니다."* 다른 프레임워크에서 `Peek`은 관례적으로
"구독 없이 값을 읽는다"인데 여기선 `store.key`가 이미 그 일을 하고
이 메소드는 **핸들**을 준다.
- **⭐ 선언은 필수, 값은 선택.** 안 넘겨도 되는 것은 타입에서 `?`
선언한다 — 그러면 `store:Of("name")``Source<string?>`로 나와
**타입이 정직해진다**. 옛 lazy 모델이 갖던 구멍(사용자: *"`Set` 을 안
해주면, 초기 값이 타입에 어긋날 수 있거든요. 애초에,
`Source<number>` 인데, nil을 조용히 가지고 있을수도 있고, 타입으로 못
막네요"*)이 이걸로 닫힌다. 부모 컴포넌트가 기본값 있는 것까지 전부
넘길 필요도 없어진다.
- **`Source` 실체는 그림자 백킹 테이블에 있다** — `store.key`가 값이면
`__index`/`__newindex`는 그 키가 store 테이블에 **없을 때만** 발동하므로
실제 `Source`를 store 테이블에 raw로 넣을 수 없다. **[2026-08-25
정정]** 옛 서술의 *"별도 `__values`류 그림자 실값 저장소도 불필요"*는
이 재설계로 뒤집혔다.
- **미선언 키의 방어선은 여전히 타입이다.** `Store<{field: T}>`로 선언된
Store에 없는 이름을 쓰면 타입 에러가 난다 — `luau-analyze` 실측에서
`store:Of("nope")`이 정확히 거부된다. 런타임에 이름이 정해지는 정당한
용도는 `:GetDynamic`(아래 "타입 추론 문제" 절)이 정식 창구.
- **`store:Names()`가 선언된 키 집합을 준다** — 타입은
`{ keyof<...> }`로 정확히 나온다. 그룹 `Attribute(...)`/`attr:NameMap()`이
이걸 요구한다(`base/attribute-plan.md`). **[2026-08-25 신설, 7라운드
`H-79`]** 이 표면이 없어서 그룹 `Attribute`의 키 집합이 접근 이력에
좌우되던 문제를 닫는다.
- **구현 스케치**: 생성 시 `table.clone(defaults or {})` 후 그 결과를
순회하며 각 슬롯을 `Source(v)`로 교체해 그림자 테이블로 삼는다
(`table.clone`이 원본의 해시/배열 슬롯 구조를 재사용해 빈 테이블에
키를 하나씩 넣는 것보다 쌈 — 2026-08-07 성능 근거 그대로).
**`or {}`가 필수다** — 무인자 `Store<<{}>>()`도 유효한데
`table.clone(nil)``table expected, got nil`로 죽는다(**[2026-08-25]**
7라운드 `H-83` 실측).
`Source()`(인자 없이 호출)는 `Source(nil)`과 동치.
- **⭐ [2026-08-25 신설] 선언은 됐는데 `defaults`에 없는 키는 `Of`가 그
자리에서 만든다.** "선언은 필수, 값은 선택"이 계약이므로
`Store<<{hp: number, name: string?}>>({hp = 100})`
`store:Of("name")`은 **정상 경로**인데, 위 스케치는 `defaults`에 있는
키만 그림자 테이블에 넣으므로 그 자리가 비어 있다. `Of`가 비어 있으면
`Source(nil)`을 만들어 저장하고 반환한다 — 타입이 `Source<string?>`
**정직하고**, 옛 lazy 모델이 갖던 구멍(`Source<number>`인데 조용히 `nil`)은
`?` 선언 강제로 이미 닫혀 있다.
```lua
function Store:Of(key)
local src = shadow[key]
if src == nil then
src = Source() -- == Source(nil)
shadow[key] = src
end
return src
end
-- __index(t, k) == t:Of(k):Get()
-- __newindex(t,k,v) == t:Of(k):Set(v)
```
**미선언 키는 여기 안 온다** — 타입에서 이미 거부되고(`store:Of("nope")`
실측), 런타임에 이름이 정해지는 경로는 `:GetDynamic`이 정식 창구다.
v1이 모든 값을 Store 하나에 몰아넣던 습관은 "당시 정적 타입이 없어 단순하게
쓰는 게 편해서"였다는 게 사용자의 회고적 재평가 — 지금은 타입이 핵심
제약이라 그 전제 자체가 더 이상 안 맞고, 2026-08-06 후속 세션의 정리로
Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐. 값 하나만
반응형으로 다루고 싶으면 Store를 통째로 만들지 말고 독립
`Source(default)`를 쓸 것(`base/source-state-plan.md`의 "Source는 독립
공개 프리미티브로 격상" 절).
## Store 값 설정 문법 — `store.key = v``store:Of(k):Set(v)`는 같은 것 (2026-08-25 재역전)
> **⭐⭐ [2026-08-25 재역전] 2026-08-06에 확정했던 "`myStore.key = value`
> 폐기, `source:Set(value)`로 전환"은 뒤집혔다.** 원문과 역전 이유는
> 이 파일(당시 이름은 store-source-record-model-reversed.md였다). 여기 옛 결정을 남겨두면
> 앞에서부터 읽는 구현자가 그걸 그대로 믿으므로 포인터만 남긴다.
**둘 다 정식 경로이고 같은 것이다.**
```lua
store.hp = 5 -- __newindex → store:Of("hp"):Set(5)
store:Of("hp"):Set(5) -- 프리미티브
```
- **왜 되살아났나**: 옛 결정의 첫 근거는 *"읽기(`Source<T>`)/쓰기(`T`)
타입이 갈려 mismatch가 남음"*이었는데, 위 재설계로 **읽기도 `T`가 되어
대칭**이다. `luau-analyze` 실측에서 `store.hp = 5`는 통과하고
`store.hp = "five"`는 정확히 걸린다.
- 옛 결정의 둘째 근거(*"`=`가 암시하는 즉시 커밋이 quad의 lazy와 안
맞음"*)도 이 자리에선 약하다 — lazy한 것은 **하류 전파**이지 Store 값
자체가 아니다. 사용자: *"연산이 안 일어난다. 진짜 현 값이 맞음."*
- 옛 결정의 셋째 근거(**"값을 바꾸는 연산엔 `:` 체이닝 허용"** 원칙,
`base/architecture.md`)는 그대로 유지된다 — `store:Of(k):Set(v)`
그 사례다.
- **`myStore "key"`(문자열 커링)는 여전히 기각**이다(2026-08-18 사용자
판정) — *"저러면 "a" 가 string 으로 들어가서, Source<T> 의 타입을 모르기도
하고, 우린 더이상 필요하지 않게 된 요소임."* 동적 키는 아래
`:GetDynamic`으로 간다.
`base/architecture.md`의 "복사(clone) 구현 지양, 팩토리 함수로 대체" 원칙과 함께
읽을 것 — v1의 문제는 metatable 체이닝으로 매번 새 테이블을 할당하며
"불변 빌더"를 흉내낸 것이었지, `:` 체이닝 문법 자체나 대입 문법 자체가
아니었음.
## 타입 추론 문제 — `store.key`(dot-access)를 1급 경로로 확정 (2026-08-04 3차 라운드, 2026-08-25 재작성)
- `store "key"`(문자열 커링)로 `state<T>`를 오버로드 함수 타입으로 정확히
추론하려는 시도는 포기하고(그 문자열 커링 자체도 **[2026-08-18] 기각**,
위 절), **`store.key`(dot-access)를 1급 경로로 확정**. Store 타입을
`index<>`/`keyof<>`로 지으면 일반 구조적 필드 타이핑으로 자동 해결되고,
문자열 리터럴 narrowing 문제 자체가 안 생김(구체적인 모양은 아래
"`store.key` 레코드 필드 타이핑" 절).
- **동적 키 경로는 명시적 메소드다**`store:GetDynamic<<T>>(name): Source<T>`.
런타임 동작 자체는 dot-access와 같고 문제는 **타입**뿐이었다: 선언되지
않은 이름은 결과 타입에 없어서 타입 에러가 난다(그게 방어선이라는 게
사용자 확정). 그래서 "런타임에 이름이 정해지는" 정당한 용도를 위해
**타입을 호출자가 직접 주는 명시적 창구**를 둔다 — 사용자 판정:
*"동적히는 여전히 그냥 Store.Name 하면 얻어는 짐. 타입 애러가 난다는
점인데, 이는 GetDynamic<T>(name): Source<T> 로 제공하는게 최선으로 보임."*
- **⭐ [2026-08-25 실측 정정] `<<T>>`는 값 호출부에서 동작한다.**
7라운드 `H-73`이 *"Luau엔 호출부 명시 타입 인자 문법이 없다"*고
단정했으나 **틀렸다** — Luau의 **generic type instantiation**
(`luau.org/types/generics/#generic-type-instantiation`)이 값 호출부에서도,
콜론 메소드에서도 `T`를 실제로 묶는다. `luau-analyze` 실측:
```lua
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>
```
원문은 **인스턴스화를 생략한 호출만** 돌려보고 단정했다. 따라서
`base/quad-types-plan.md`의 이중 꺾쇠 관례는 타입 자리 전용이 아니다.
- **⭐ [2026-08-25 확정] 표면은 전부 콜론 메소드이고, 예약 키 충돌은
타입 함수가 잡는다.** 예약 이름은 `Of`/`Names`/`GetDynamic`/`__store`
(팬텀 필드)다.
- **안 잡으면 조용히 죽는다** — 실측에서 사용자 키가 예약 이름과 겹치면
교집합이 뭉개져 **그 필드의 타입 검사가 통째로 꺼진다**(음성 대조군이
진단 0건으로 통과했다). 시끄럽게 막히는 게 아니라 그냥 지나간다.
- 그래서 `T`**검증만 하고 그대로 통과시키는** 작은 `type function`
둔다. 겹치면 사용 지점에
`TypeError: quad.Store: "Of" is a reserved key`가 뜬다.
```lua
type function CheckReserved(t: type): type
-- 겹치면 print(...) + return types.never, 아니면 t를 그대로 반환
end
type Store<T> = StoreOf<CheckReserved<T>>
```
- **`error()`는 못 쓴다** — `type function` 자체가 실패한 걸로 판정돼
버려진다. `print(...)` + `return types.never` 조합만 된다
(`luau-test/done/23-type-quadtypes-checkversion-addplugin.luau`가
기록해둔 사실이고 `type-version-check`가 이미 쓰는 패턴).
- **[2026-08-25 해소] 옛 "탑레벨 함수로 옮길지" 미결은 닫혔다** —
콜론 유지로 확정했고, 예약 키 문제는 위 타입 함수가 받는다. 팬텀
필드(`__store`)의 사용은 UB로 둔다.
- 이 패턴은 Store에만 국한되지 않고 **인스턴스 생성까지 관통하는 프로젝트
전역 관습으로 확정**됨 — 단 이벤트는 이후 4차 라운드에서 이 관습의
**유일한 예외**로 빠졌음(PA님 방식인 문자열 키+런타임 리플렉션으로 전환).
`base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학"
절이 최신 확정 내용.
### `store.key` 레코드 필드 타이핑 — `index<>`/`keyof<>`로 해결 (2026-08-25 재작성, `WrapStore` 폐기)
> **⭐⭐ [2026-08-25] 옛 `WrapStore`/`ProcessStoreType` 타입 함수 접근은
> 폐기됐다.** 원문은 이 파일(당시 이름은 store-source-record-model-reversed.md였다).
> 7라운드 `H-75`/`H-76`이 그 접근의 두 한계를 실측했는데(평평한 선언이면
> `store.key:Compute(무주석 콜백)`이 깨짐 / `type function`이 바깥 타입
> 별칭을 참조 못 해 구조를 통째로 중복 작성해야 하고 메소드 self
> 파라미터가 불변이라 필드 하나만 어긋나도 대입 실패), 아래 모양은
> **결과 타입을 합성하지 않으므로** 두 한계에 아예 안 닿는다.
**확정 형태** — 팬텀 필드에 선언 원형을 싣고, `index<>`/`keyof<>`로 꺼낸다.
```lua
type StoreOf<T> = {
__store: T, -- 팬텀. 사용은 UB.
Of: <Self, K>(self: Self, key: K & keyof<index<Self, "__store">>)
-> Source<index<index<Self, "__store">, K>>,
Names: <Self>(self: Self) -> { keyof<index<Self, "__store">> },
GetDynamic: <T>(self: any, name: string) -> Source<T>,
} & T
type Store<T> = StoreOf<CheckReserved<T>>
```
- `& T`가 dot-access(`store.hp`)를 그대로 만들어준다 — 타입 인자가
**평범한 값 타입**이므로 `store.hp``number`다.
- `K & keyof<...>`가 **싱글톤을 보존**한다. 사용자가 실측으로 확인한
성질이다: 타입 후보 중에 싱글톤이 있으면 `string`으로 뭉개지지 않고
`"hp"` 그대로 넘어간다. 그래서 `index<T, K>`가 그 키의 실제 타입을
뽑아낸다.
- **`luau-analyze` 실측**(전부 `--!strict`, 양성 + 음성 대조군):
| 검사 | 결과 |
|---|---|
| `store.hp` | `number` ✅ (음성 대조군 걸림) |
| `store.hp = 5` / `= "five"` | 통과 / **걸림** ✅ |
| `store:Of("hp")` | `Source<number>` ✅ (음성 대조군 걸림) |
| `store:Of("name")`(`string?` 선언) | `Source<string?>` ✅ |
| `store:Of("nope")` | **거부** ✅ |
| `store:Names()` | `{ "hp" \| "name" }` ✅ |
| `store:Of("hp"):Set(3)` / `:Set("3")` | 통과 / **걸림** ✅ |
| `Store<{ Of: number }>` | **`"Of" is a reserved key`** ✅ |
| `store:Of("hp"):Compute(무주석 콜백)` | ⚠️ **파라미터 주석 필요**(아래 캐비엇) |
- **⚠️ [2026-08-25 정정] `store.key:Compute(…)`라고 적었던 표 행은 거짓이었다.**
실측한 적이 없었고(`/code-review high`가 잡음), 애초에 새 모델에서
`store.key`는 **값**이라 `:Compute`가 없다
(`Type 'number' does not have key 'Compute'`). 파생은 프리미티브를
거친다 — `store:Of("hp"):Compute(...)`.
- **`store:Of(k)`의 반환값 추론은 무주석으로 정확하다 — Store 특유의
타입 문제는 없다.** 실측(`audit/type-store-index-keyof/spikes/08`):
```lua
local hp = store:Of("hp") -- 무주석
local a: number = hp:Get() -- ✅ (string 대조군은 정확히 걸림)
hp:NoSuchMethod() -- ✅ 걸림
hp:Set(5) -- ✅ (`:Set("5")`는 걸림)
local nm = store:Of("name") -- Source<string?>`?` 선언이 그대로 드러남
```
- **걸리는 자리는 하나 — `:Compute`/`:With`의 콜백 파라미터 무주석 추론**이고,
그건 Store가 만든 게 아니라 `base/typing-limits.md` §1의 **문제 A**
자체다. 해법도 그 문서가 정한 그대로 — **파라미터에 주석을 단다**(실측
통과):
```lua
hp:Compute(function(s) return s:Get() * 2 end) -- ❌ 진단
hp:Compute(function(s: StateData<number>) return s:Get() * 2 end) -- ✅
```
§1②쪼개기가 평범한 `Source`에서 이 자리를 덮어주는 것과 달리 `Of`
거치면 안 걸린다(`Self` 제네릭 때문 — `spikes/07`). `& T` 형태에서
`Self`를 빼는 건 불가능하고(`self: any`는 탐색 기록의 `Store4`처럼 통째로
깨진다), §0의 *"Luau의 한계를 우회하려고 타입/API를 비틀지 않는다"*에
따라 표면을 비틀지 않는다.
- **파생 결과에 명시 주석을 다는 상시 규약은 그대로 유효하다**
`audit/type-recursion-issue/REPORT.md` 3-1절이 실측(`43`)으로 확인한
대로, 명시 주석 이후 **다운스트림 전체는 정상 체크**되고 구멍은 그 한
줄(RHS가 실제로 그 타입인지)뿐이다.
- **`__call` 경로는 죽었다** — 타입 레벨 `__call``self`를 못 받고,
`typeof(f<<T>>)`로 타입 인자를 넘기는 것도 실패한다(사용자 실측).
그래서 Store를 콜러블로 만드는 안은 성립하지 않는다. 같은 사실이
`base/source-state-plan.md``:Apply`에도 적용된다(7라운드 `H-94`).
---
## 이 시도 이전의 원문은 archive에 없다
`WrapStore`/`ProcessStoreType` 접근(2026-08-12~2026-08-15)은 `H-75`/`H-76`
실측으로 폐기됐고, 그 폐기는 **철회되지 않았다**`base/typing-limits.md`
§5의 폐기 배너와 `luau-test/STATUS.md``16`/`21` 항목이 소스다.
`store.key = value` 폐기(2026-08-06)와 dot-access 1급 경로도 **현행**이라
`base/store-plan.md`에 그대로 있다.

View file

@ -124,7 +124,7 @@ relate4의 살아있는 엔트리 총 개수: 0 (기대 0)
| `13-type-ref-preref-subtype` | ✅ 통과 | `PreRef<T>``Ref<T>` 자리에 대입 가능 — 진단 0건 |
| `14-type-nilable-default-overload` | ⚠️ 부분통과 | 의도한 오용은 정확히 막지만 **정상 nilable 사용례까지 같이 막아** 현 스케치로는 채택 불가 |
| `15-type-compute-trailing-deps-typepack` | ❌ 검증불가 | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 파일 전체가 파싱 실패. 다만 파서가 복구 후 낸 진단에서 **아래 1번 이슈**가 드러남 |
| `16-type-store-key-typefunction` | ❌ 실패(당시) → **[2026-08-15] ✅ 통과로 복구** | `type function` 스케치의 `types.newfunction` 시그니처가 설치된 버전의 실제 API와 안 맞음(레코드 대신 배열을 넘기고 있었음) — 설계 문제 아니라 API 버전 드리프트였음이 나중에 확인됨. 상세: `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절, `luau-test/done/16-type-store-key-typefunction.luau` |
| `16-type-store-key-typefunction` | ❌ 실패(당시) → **[2026-08-15] ✅ 통과로 복구** | `type function` 스케치의 `types.newfunction` 시그니처가 설치된 버전의 실제 API와 안 맞음(레코드 대신 배열을 넘기고 있었음) — 설계 문제 아니라 API 버전 드리프트였음이 나중에 확인됨. 상세: `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절, `luau-test/rewrite-required/16-type-store-key-typefunction.luau` |
## ⚠️ 실측으로 드러난 진짜 설계 이슈 — `:Compute(fn)`의 lazy 핸들 계약이 Luau 추론과 충돌

View file

@ -197,7 +197,7 @@ quad 설계 문제가 아니라 **`setmetatable`+`typeof(genericFn<<T>>())`
## 6. `type function``Store<T>` 필드 합성은 고쳐짐, 재귀 `Compute`는 막다른 길
### 6-1. `luau-test/done/16-type-store-key-typefunction.luau` 복구 성공(원래 `rewrite-required/`)
### 6-1. `luau-test/rewrite-required/16-type-store-key-typefunction.luau` 복구 성공(원래 `rewrite-required/`)
`spikes/09-typefunction-store-key-FIXED.luau` — 원래 스파이크가 깨진
이유는 설계 문제가 아니라 **API 버전 드리프트**: `types.newfunction`

View file

@ -0,0 +1,201 @@
# 실측 — Store 타이핑을 `index<>`/`keyof<>`로 다시 짜기 (2026-08-25)
> **⛔⛔ [2026-08-25, 같은 날] 이 실측이 뒷받침하던 설계는 철회됐다.**
> 여기 기록된 **측정값 자체는 전부 유효**하다(재현 가능). 다만 그 위에
> 세웠던 결론 — "`index<>`/`keyof<>` + 팬텀 필드로 Store를 짓는다" — 은
> 같은 날 철회됐고, 지금 Store는 **타입 함수를 안 쓰고** 타입 인자에
> `Source<T>`를 직접 써서 평범한 레코드로 짓는다(`base/store-plan.md`).
> 철회 이유는 `archive/store-value-field-redesign-withdrawn.md`,
> 원칙은 `base/typing-limits.md` §0(*"타입 함수는 진단까지만"*).
>
> **그래도 이 문서를 남기는 이유**: (1) `H-73` 반증(`f<<T>>()`가 값
> 호출부에서 동작)은 지금도 유효한 확정 사실이고, (2) 실패한 formulation
> 표는 "다시 시도하지 말 것" 목록으로 계속 값을 하며, (3) 싱글톤 보존
> 규칙과 `__call`이 죽은 경로라는 것도 Luau 자체의 성질이라 그대로다.
> (4) `spikes/07`이 잰 **`Self` 제네릭이 콜백 파라미터 추론을 깬다**는
> 것이 철회의 직접 근거 중 하나였다.
**무엇인가**: `WrapStore`/`ProcessStoreType` 타입 함수로 결과 타입을
**합성**하던 Store 타이핑을 폐기하고 `index<>`/`keyof<>` + 팬텀 필드로
다시 지을 수 있는지를 `luau-analyze`로 판정한 기록. **계획이 아니라
실측 결과**다 — 지금 유효한 설계는 `base/store-plan.md`가 소스이고,
뒤집힌 옛 모델은 `archive/store-value-field-redesign-withdrawn.md`.
**발단**: 7라운드 손 트레이싱 `H-75`/`H-76`이 `WrapStore` 접근의 두 한계를
실측했고(평평한 합성이면 `store.key:Compute(무주석 콜백)`이 깨짐 /
`type function`이 바깥 타입 별칭을 참조 못 해 구조를 통째로 중복 작성해야
하고 self 파라미터 불변성 때문에 필드 하나만 어긋나도 대입 실패),
**사용자가 직접 `test.luau`로 대안을 탐색**했다.
**⚠️ 스크립트를 같이 두는 예외적 구성** — 판정이 "여러 formulation 대조"라
개별 파일을 직접 돌려야 재현된다(`audit/type-recursion-issue/`와 같은 이유).
전부 `luau-analyze <파일>`로 돌린다. **[2026-08-25 기준] `.luaurc`
`languageMode: strict`이므로 진단이 이 프로젝트에 그대로 적용된다.**
| 파일 | 무엇을 보나 |
|---|---|
| `spikes/01-exploration.luau` | **사용자 탐색 원본**(루트 `test.luau`). `const``local`만 고쳤다. 주석에 적힌 기대 진단이 곧 판정이고, 아래 "실패한 formulation" 절이 이 파일을 읽는 지도다 |
| `spikes/02-toplevel-form.luau` | 조작 표면을 **탑레벨 함수**로 뺀 형태 — 예약 키가 팬텀 하나로 줄어드는지 |
| `spikes/03-value-index-form.luau` | **확정된 형태** — 타입 인자가 평범한 값 타입이고 `Of``Source<>`를 씌움 |
| `spikes/04-reserved-key-silent.luau` | 예약 키 충돌 시 **타입 검사가 조용히 죽는지** |
| `spikes/05-checkreserved.luau` | `CheckReserved` 타입 함수가 그 침묵을 **시끄럽게** 만드는지 |
| `spikes/06-generic-instantiation.luau` | `f<<T>>()`가 값 호출부·콜론 메소드에서 `T`를 실제로 묶는지(`H-73` 반증) |
| `spikes/07-self-generic-inference.luau` | **[2026-08-25 추가]** 무주석 콜백 추론이 어느 구성에서 깨지는지 — `Self` 제네릭만 걸리고 `index<>`/`keyof<>`/`K` 제네릭은 안 걸린다 |
---
## 결론
### ✅ 성립 — 마법 타입 함수 없이 Store 전체가 타이핑된다
```lua
type StoreOf<T> = {
__store: T, -- 팬텀. 사용은 UB.
Of: <Self, K>(self: Self, key: K & keyof<index<Self, "__store">>)
-> Source<index<index<Self, "__store">, K>>,
Names: <Self>(self: Self) -> { keyof<index<Self, "__store">> },
} & T
type Store<T> = StoreOf<CheckReserved<T>>
```
`spikes/03`의 양성 6건이 전부 진단 0건이고 음성 대조군 5건이 전부 정확히
걸렸다(**[2026-08-25 정정]** 아래 표의 마지막 두 행 — `:Names()`
`Compute` — 은 `03`이 아니라 각각 `02`/`07`이 근거다):
| 검사 | 결과 |
|---|---|
| `store.hp` | `number` ✅ (음성 대조군 걸림) |
| `store.hp = 5` / `= "five"` | 통과 / **걸림** ✅ |
| `store:Of("hp")` | `Source<number>` ✅ (음성 대조군 걸림) |
| `store:Of("name")`(`string?` 선언) | `Source<string?>` ✅ |
| `store:Of("nope")` | **거부** ✅ |
| `store:Names()` | `{ "hp" \| "name" }` ✅ |
| `store:Of("hp"):Set(3)` / `:Set("3")` | 통과 / **걸림** ✅ |
| `store:Of("hp"):Compute(무주석 콜백)` | ⚠️ **파라미터 주석 필요**(아래 캐비엇) |
**⚠️ [2026-08-25 정정] 이 표에 있던
*"`store.key:Compute(무주석 콜백)` 파라미터 추론 정상 ✅"* 행은 **실측한 적이
없고 거짓이었다**(`/code-review high`가 잡음 — `spikes/03``Compute`
아예 없다). 새 모델에서 `store.key`는 **값**이라 `:Compute`가 없고
(`Type 'number' does not have key 'Compute'`), 파생은
`store:Of("hp"):Compute(...)`로 만든다.
**정본 선언으로 다시 재보니 두 축이 갈린다**(`spikes/08`):
| 검사 | 결과 |
|---|---|
| `local hp = store:Of("hp")``hp:Get()` | **`number`** ✅ (무주석, `string` 대조군 걸림) |
| `hp:NoSuchMethod()` | ✅ 걸림 |
| `hp:Set(5)` / `hp:Set("5")` | 통과 / **걸림** ✅ |
| `store:Of("name")`(`string?` 선언) | `Source<string?>` ✅ (`number` 대조군 걸림) |
| `hp:Compute(function(s) … end)` | ❌ **콜백 파라미터**만 안 잡힘 |
| `hp:Compute(function(s: StateData<number>) … end)` | ✅ 통과 |
- **반환값 추론은 무주석으로 정확하다**`store:Of(k)` 자체엔 Store 특유의
타입 문제가 **없다**.
- **걸리는 건 `:Compute`/`:With`의 콜백 파라미터 하나**이고, 그건 Store가
만든 게 아니라 `base/typing-limits.md` §1의 **문제 A** 그 자체다.
해법도 그 문서가 정한 대로 파라미터 주석이고, 실측으로 통과한다.
- §1②쪼개기가 평범한 `Source`에서 이 자리를 덮어주는 것과 달리 `Of`
거치면 안 걸린다(`spikes/07` — `Self` 제네릭). `& T` 형태에서 `Self`
빼는 건 불가능하다(`self: any`는 `Store4`처럼 통째로 깨진다). §0의
*"Luau의 한계를 우회하려고 타입/API를 비틀지 않는다"*에 따라 표면을
비틀지 않는다.
- 파생 결과에 명시 주석을 다는 상시 규약은 그대로다 —
`audit/type-recursion-issue/REPORT.md` 3-1절(`43`)이 확인한 대로 명시
주석 이후 다운스트림 전체는 정상 체크된다.
별도로 확인한 것 하나: `:Compute`**반환 타입** 음성 대조군은 평범한
`Source`에서도 안 걸리는데, 그건 이 Store 설계와 무관한 `Compute` 시그니처
쪽 성질이다(과잉 주장하지 말 것).
### ⭐ 싱글톤 보존 규칙 — 어떤 형태가 `string`으로 안 뭉개지는가
이 설계 전체가 **`K``"hp"` 같은 싱글톤으로 넘어온다**는 데 기대고 있어서
따로 실측했다(`spikes/01`의 `singletonTest1`~`4`).
| 선언 | `f("bbb")``T` | 진단 |
|---|---|---|
| `f<T>(input: T): T` | `string` ❌ 뭉개짐 | 없음 |
| `f<T>(input: T & string): T` | `unknown` ❌ | 없음 |
| `f<T>(input: T & ""): T` | `"bbb"` ✅ | **에러**(`"bbb"`가 `""`의 서브타입이 아님) |
| **`f<T>(input: T \| "" \| string): T`** | `"bbb"` ✅ | **없음** ✅ |
| **`f<T>(input: T & ("A" \| "B")): T`** | `"B"` ✅ | 없음(범위 밖이면 정확히 걸림) ✅ |
**규칙**: 타입 후보 중에 **싱글톤이 있으면** 추론이 `string`으로 안 뭉개고
있는 그대로 넘긴다. `K & keyof<...>`가 바로 그 형태라(키 이름들의 싱글톤
유니온) 의도대로 동작하고, **범위 밖 키는 교집합이 비어 정확히 거부된다.**
사용자 관찰: *"K& 를 걸고 유니온 스트링을 걸면, K 가 싱글톤으로써, string 으로
뭉개지지 않고 전해진다."*
### ⛔ 실패한 formulation — 왜 다른 모양은 안 되는가
`spikes/01`이 순서대로 밟은 막다른 길들. 다시 시도하지 말 것:
| 형태 | 무엇이 막았나 |
|---|---|
| `Store2<T>`: `__call: typeof(getter2<<T>>)` | **완전 실패**`type function` 밖에서도 타입 별칭 인자를 `typeof`에 실어 나를 수 없다. `H-76`이 관측한 것과 같은 벽 |
| `Store<T>`: `__call: <K>(K & keyof<T>) -> index<T, K>` | `Argument count mismatch. Function expects 1 argument, but 1 is specified`**타입 레벨 `__call`은 `self`를 못 받는다**(인자 목록만 남음) |
| `Store3<T>`: `Set`/`Peek`을 **점 호출**로 | 동작은 하나 `Set`의 시그니처만 봐선 뭘 넣어야 할지 감이 안 온다(사용자 판정). 값 타입을 알기 어려워 직접 인덱스를 허용해야 함 |
| `Store4<T>`: `{ Peek: <K>(self: any, ...) } & T` | `Property 'any' does not exist on type '{...}'``& T``self: any` 자리에 끼어들어 깨진다 |
| `Store5<T>`: `self: Store5<Inner>`로 self를 명시 | `Peek`의 인자가 **에러 타입**이 된다 |
| `Store6<T>`: **`__realtype` 팬텀 + `index<index<Self,"__realtype">, K>`** | ✅ **성립** — 이게 확정 형태의 원형이다 |
**`__call` 경로는 죽었다**는 게 이 표의 요지이고, 그 사실이
`base/source-state-plan.md``:Apply`(7라운드 `H-94`)와
`base/gate-plan.md` 2번에 반영됐다 — 애플리커티브 팩토리는 `__call`
아니라 **지정된 필드**로 자기를 노출한다.
### ⚠️ 예약 키 충돌은 **조용히** 타입 검사를 끈다
`spikes/04`에서 `Store<{ Peek: number, __store: boolean }>`로 걸었더니
**진단 0건**인데, 그 안엔 걸려야 할 음성 대조군이 있다:
```lua
local g3: string = clash.Peek -- Peek 은 number 인데 진단 없음 ❌
```
즉 사용자 키가 예약 이름과 겹치면 교집합이 뭉개져 **그 필드에 대한 타입
검사가 통째로 죽는다.** 시끄럽게 막히는 게 아니라 그냥 지나간다.
### ✅ `CheckReserved`가 그 침묵을 없앤다
`spikes/05`에서 확인:
```
TypeError: quad.Store: "Of" is a reserved key
```
**`error()`는 못 쓴다** — `type function` 자체가 실패한 걸로 판정돼 버려진다.
`print(...)` + `return types.never` 조합만 된다
(`luau-test/done/23-type-quadtypes-checkversion-addplugin.luau`가 기록해둔
사실이고 `type-version-check`가 이미 쓰는 패턴). 이 타입 함수는 `T`
**검증만 하고 그대로 통과**시키므로 `H-75`/`H-76`이 지적한 합성의 두 한계에
아예 안 닿는다.
### ✅ `f<<T>>()`가 값 호출부에서 동작한다 — `H-73` 반증
`spikes/06`:
```lua
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>
```
7라운드 `H-73`**인스턴스화를 생략한 호출만** 돌려보고 *"Luau엔 호출부
명시 타입 인자 문법이 없다"*로 단정했다. 실제로는 Luau의 **generic type
instantiation**(`luau.org/types/generics/#generic-type-instantiation`)이
값 호출부에서도, **콜론 메소드에서도** `T`를 묶는다. 그래서
`base/quad-types-plan.md`의 이중 꺾쇠 관례는 타입 자리 전용이 아니다.
---
## 이 실측이 바꾼 문서
- `base/store-plan.md` — "Store = Source들의 이름 붙은 모음" / "Store 값 설정 문법" /
"타입 추론 문제" / "`store.key` 레코드 필드 타이핑" 네 절 재작성
- `base/typing-limits.md` — §5가 폐기 배너를 달았고, §7에 싱글톤/인스턴스화 항목 추가
- `base/quad-types-plan.md` — 이중 꺾쇠 관례가 값 호출부까지 확장
- `archive/store-value-field-redesign-withdrawn.md` — 뒤집힌 옛 모델 원문
- `luau-test/STATUS.md` — 스파이크 `16`/`21`이 `rewrite-required/`

View file

@ -0,0 +1,155 @@
--!strict
-- [보존] 사용자가 2026-08-25에 직접 돌린 탐색 원본(루트 `test.luau`).
-- `const` -> `local`만 고쳤고 나머지는 그대로다. 주석의 기대 진단이 곧 판정.
local function test2<K>(k: K & ("A" | "B"), v: number): K
return nil :: any
end
local t: "B" = test2("B", 1)
-- TypeError: Expected this to be '"A"', but got '"B"'
local t: "A" = test2("B", 1) -- 에러 발생
local function test3<K>(k: K & ("A" | "B")): index<{ A: number }, K>
return nil :: any
end
-- number
local a = test3("A")
-- TypeError: Expected this to be 'string', but got 'number'Luau1000
local a: string = test3("A")
local function test4<S, K>(s: S, k: K & ("A" | "B")): index<S, K>
return nil :: any
end
-- boolean
local b = test4({ A = true}, "A")
-- now we should test index<Self...>
local function getter<Self, K>(self: Self, key: K & (keyof<index<Self, "__realtype">>)): index<index<Self, "__realtype">, K>
return nil :: any
end
local me = {
__realtype = {
name = true
},
getter = getter
}
-- name 이 자동완성됩니다. 게다가
me:getter("name")
local t = me:getter("name") -- boolean
-- 음성 대조해보면. 이렇습니다
local t: string = me:getter("name")
-- __call 에 대해서 시도해보죠
local mock = (nil :: any) :: setmetatable<{
__realtype: {
name: boolean,
}
}, {
__call: typeof(getter),
}>
-- mock() -- self 를 받지 못하네요. __call 은 타입에 인자만 남아야하나봅니다.
type Store<T> = setmetatable<{
}, {
__call: <K>(K & keyof<T>) -> index<T, K>
}>
local sinst = (nil :: any) :: Store<{
a: boolean,
b: number,
}>
-- TypeError: Argument count mismatch. Function expects 1 argument, but 1 is specifiedLuau1008
-- 이해가 안가지만, 아마 어디선가 문제를 일으키나보네요
local b = sinst("a")
-- 완전히 실패합니다. 이전에 확인한것과 같게, T는 넘어갈 수 없다는 논리
local function getter2<Self, K>(key: K & (keyof<Self>)): index<Self, K>
return nil :: any
end
type Store2<T> = setmetatable<{
}, {
__call: typeof(getter2<<T>>)
}>
local sinst2 = (nil::any)::Store2<{
a: boolean, b: number
}>
sinst2()
-- 따라서 이것이 최선일 수 있어요
type Store3<T> = {
Set: <K>(key: K & keyof<T>, value: index<T, K>) -> Store3<T>,
Peek: <K>(key: K & keyof<T>) -> index<T, K>,
}
local sinst3 = (nil::any)::Store3<{
test: boolean
}>
-- 에러가 남. 다만, set 의 시그니처 보기로는 뭘 넣어야할지 바로 감이 안 잡힘.
sinst3.Set("test", 1) -- test 가 자동완성됩니다
-- Set 에 있어서는, 값이 뭔지 알기 힘들기에 직접 인덱스를 허용해야함.
type Store4<T> = {
Peek: <K>(self: any, key: K & keyof<T>) -> index<T, K>,
} & T
local sinst4 = (nil :: any) :: Store4<{
test: boolean
}>
-- TypeError: Property 'any' does not exist on type '{ test: boolean }'Luau1038
-- T 가 들어간 것이 self: any 자리에 들어가니, 에러가 납니다
sinst4:Peek("test")
type Store5<T> = {
Peek: <Inner, K>(self: Store5<Inner>, key: K & keyof<Inner>) -> index<Inner, K>,
} & T
-- peek 의 인자가 에러타입이 됨
local sinst5 = (nil :: any) :: Store5<{
test: boolean
}>
type Store6<T> = {
__realtype: T,
Peek: <Self, K>(self: Self, key: K & keyof<index<Self, "__realtype">>) -> index<index<Self, "__realtype">, K>,
} & T
-- peek 의 인자가 에러타입이 됨
local sinst6 = (nil :: any) :: Store6<{
test: boolean
}>
-- TypeError: Expected this to be 'number', but got 'boolean'Luau1000
local aaa: number = sinst6:Peek("test")
sinst6:Peek("bbb") -- TypeError: Property '"bbb"' does not exist on type '{ test: boolean }'
-- 이것으로 남는 결론: K& 를 걸고 유니온 스트링을 걸면, K 가 싱글톤으로써, string 으로 뭉개지지 않고 전해진다.
-- K & (...) 상태에서 bbb 를 넣을 순 있음. 그치만 잘 에러가 남. 보면 그대로 index 함수로 잘 싱글톤으로 들어간다는게 이상하게 보이는 부분.
local function singletonTest1<T>(input: T): T
return input
end
local singletonTest1v = singletonTest1("bbb") -- string
local function singletonTest2<T>(input: T & string): T
return input
end
local singletonTest2v = singletonTest2("bbb") -- unknown
local function singletonTest2<T>(input: T & ""): T
return input
end
local singletonTest2v = singletonTest2("bbb") -- "bbb"
-- TypeError: Expected this to be '"" & "bbb"', but got '"bbb"';
-- the 2nd component of the intersection is `""`, and `"bbb"` is not a subtype of `""`
-- **여기서 얻는점: 타입 후보 중에서 싱글톤이 있으면, 싱글톤으로 들어간다. 아마도
-- 이것은 싱글톤 중에서 메치를 해야하니, 있는 그대로 string 으로 뭉개지 않고 bbb 로 넘기는듯
-- 싱글톤으로 넘기는 조건이 있다는것이다
local function singletonTest3<T>(input: T | "" | string): T
return input
end
local singletonTest3v = singletonTest3("bbb") -- "bbb", 에러도 없다.
local singletonTest4v: "a" = singletonTest3("bbb") -- TypeError: Expected this to be '"a"', but got '"bbb"'
-- 확실하다.

View file

@ -0,0 +1,31 @@
--!strict
type Source<T> = { Value: T, Set: (self: Source<T>, v: T) -> Source<T> }
-- 예약 키를 팬텀 하나로 줄인다. Peek/Set/Names 는 전부 탑레벨.
type Store<T> = { __store: T } & T
local function peek<Self, K>(s: Self, key: K & keyof<index<Self, "__store">>)
: index<index<index<Self, "__store">, K>, "Value">
return nil :: any
end
local function names<Self>(s: Self): { keyof<index<Self, "__store">> }
return nil :: any
end
local store = (nil :: any) :: Store<{ hp: Source<number>, name: Source<string> }>
local f: Source<number> = store.hp -- 직접 접근
local g: Source<string> = store.hp -- 음성 대조군
local a: number = peek(store, "hp")
local b: string = peek(store, "hp") -- 음성 대조군
local c: string = peek(store, "name")
local d = peek(store, "nope") -- 음성 대조군
local n: { "hp" | "name" } = names(store)
-- 사용자가 Peek / Set / Names 를 키로 써도 되는가
local free = (nil :: any) :: Store<{ Peek: Source<number>, Set: Source<boolean>, Names: Source<string> }>
local p: number = peek(free, "Peek")
local q: boolean = peek(free, "Set")
local w: string = peek(free, "Names")
local z: number = peek(free, "Set") -- 음성 대조군

View file

@ -0,0 +1,34 @@
--!strict
type Source<T> = { Value: T, Get: (self: Source<T>) -> T, Set: (self: Source<T>, v: T) -> Source<T> }
-- T 는 평범한 값 타입. __index/__newindex 가 Get/Set 을 대행하고,
-- Peek 이 Source<> 를 씌워 준다.
type Store<T> = {
__store: T, -- 팬텀. 사용은 UB.
Peek: <Self, K>(self: Self, key: K & keyof<index<Self, "__store">>)
-> Source<index<index<Self, "__store">, K>>,
} & T
local store = (nil :: any) :: Store<{ hp: number, name: string }>
-- (1) __index → 값
local a: number = store.hp
local b: string = store.hp -- 음성 대조군
-- (2) __newindex → 대입
store.hp = 5
store.hp = "five" -- 음성 대조군
-- (3) Peek → Source<값>
local c: Source<number> = store:Peek("hp")
local d: Source<string> = store:Peek("hp") -- 음성 대조군
local e: Source<string> = store:Peek("name")
local f = store:Peek("nope") -- 음성 대조군
-- (4) Peek 결과를 반응형으로 씀
store:Peek("hp"):Set(3)
store:Peek("hp"):Set("3") -- 음성 대조군
-- (5) 사용자가 Peek 을 자기 키로 쓰면
local clash = (nil :: any) :: Store<{ Peek: number }>
local g = clash:Peek("Peek")

View file

@ -0,0 +1,14 @@
--!strict
type Source<T> = { Value: T, Get: (self: Source<T>) -> T, Set: (self: Source<T>, v: T) -> Source<T> }
type Store<T> = {
__store: T,
Peek: <Self, K>(self: Self, key: K & keyof<index<Self, "__store">>)
-> Source<index<index<Self, "__store">, K>>,
} & T
-- 사용자 키 이름이 예약 이름과 겹칠 때
local clash = (nil :: any) :: Store<{ Peek: number, __store: boolean }>
local g1: Source<number> = clash:Peek("Peek") -- 메소드로 부를 수 있나 / 무엇이 나오나
local g2: number = clash.Peek -- 필드로 읽으면
local g3: string = clash.Peek -- 음성 대조군
local g4 = clash:Peek("__store") -- 팬텀 이름을 키로

View file

@ -0,0 +1,36 @@
--!strict
type Source<T> = { Value: T, Get: (self: Source<T>) -> T, Set: (self: Source<T>, v: T) -> Source<T> }
type function CheckReserved(t: type): type
if t:is("table") then
for k in t:properties() do
if k:is("singleton") then
local name = k:value()
if name == "Of" or name == "__store" then
print("quad.Store: \"" .. tostring(name) .. "\" is a reserved key")
return types.never
end
end
end
end
return t
end
type StoreOf<T> = {
__store: T,
Of: <Self, K>(self: Self, key: K & keyof<index<Self, "__store">>)
-> Source<index<index<Self, "__store">, K>>,
} & T
type Store<T> = StoreOf<CheckReserved<T>>
-- 정상
local good = (nil :: any) :: Store<{ hp: number, name: string? }>
local a: number = good.hp
local b: string? = good.name
local c: Source<number> = good:Of("hp")
local d: Source<string?> = good:Of("name")
local e: Source<number> = good:Of("name") -- 음성 대조군
-- 예약 키 충돌 → never 로 무너뜨림
local bad = (nil :: any) :: Store<{ Of: number }>
local f = bad.Of

View file

@ -0,0 +1,15 @@
--!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
-- 양성: 명시 인스턴스화가 T를 묶는가
local ok: Source<number> = store:GetDynamic<<number>>("x")
-- 음성 대조군: 묶였다면 여기서 반드시 걸려야 한다
local bad: Source<string> = store:GetDynamic<<number>>("y")
-- 인스턴스화 생략하면?
local none: Source<number> = store:GetDynamic("z")

View file

@ -0,0 +1,31 @@
--!strict
type State<T> = { Value: T, Get: (self: State<T>) -> T,
Compute: <U>(self: State<T>, fn: (v: T) -> U) -> State<U> }
type Source<T> = State<T> & { Set: (self: Source<T>, v: T) -> Source<T> }
type Rec = { hp: number }
-- 1) 제네릭 없이 그냥 Source<number> 를 주는 함수
local function direct(): Source<number> return nil :: any end
local r1 = direct():Compute(function(v) return v + 1 end)
-- 2) index<> 만 거친 것 (Self 없음, K 없음)
local function viaIndex(): Source<index<Rec, "hp">> return nil :: any end
local r2 = viaIndex():Compute(function(v) return v + 1 end)
-- 3) K 제네릭 + index<T,K> (Self 없음)
local function viaK<K>(key: K & keyof<Rec>): Source<index<Rec, K>> return nil :: any end
local r3 = viaK("hp"):Compute(function(v) return v + 1 end)
-- 4) Self 제네릭까지 (지금 확정 형태)
type Store<T> = { __store: T,
Of: <Self, K>(self: Self, key: K & keyof<index<Self, "__store">>)
-> Source<index<index<Self, "__store">, K>> } & T
local store = (nil :: any) :: Store<Rec>
local r4 = store:Of("hp"):Compute(function(v) return v + 1 end)
-- 5) 중간 변수에 명시 주석을 달면?
local s5: Source<number> = store:Of("hp")
local r5 = s5:Compute(function(v) return v + 1 end)
-- 6) 콜백 파라미터에 주석
local r6 = store:Of("hp"):Compute(function(v: number) return v + 1 end)

View file

@ -0,0 +1,32 @@
--!strict
export type StateData<T> = { Get: (self: StateData<T>) -> T }
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
}
export type Source<T> = State<T> & { Value: T, Set: (self: Source<T>, v: T) -> Source<T> }
type Rec = { hp: number, name: string? }
type Store<T> = {
__store: T,
Of: <Self, K>(self: Self, key: K & keyof<index<Self, "__store">>)
-> Source<index<index<Self, "__store">, K>>,
} & T
local store = (nil :: any) :: Store<Rec>
-- (A) 반환값 무주석 추론 — 먹히는가?
local hp = store:Of("hp")
local a1: number = hp:Get() -- 통과해야 함
local a2: string = hp:Get() -- 음성 대조군: 걸려야 함
local a3 = hp:NoSuchMethod() -- 음성 대조군: 걸려야 함
hp:Set(5)
hp:Set("5") -- 음성 대조군: 걸려야 함
-- (B) name 은 string? 인가
local nm = store:Of("name")
local b1: string? = nm:Get()
local b2: number = nm:Get() -- 음성 대조군
-- (C) Compute 콜백 파라미터 무주석 (문제 A)
local c1 = hp:Compute(function(s) return s:Get() * 2 end)
-- (D) 콜백 파라미터에 주석을 달면
local d1 = hp:Compute(function(s: StateData<number>) return s:Get() * 2 end)

View file

@ -262,8 +262,9 @@ quad/
│ └── src/
│ ├── Source.luau # 값의 근원, 단일 지점. Source가 State를 구조적으로 만족(`__index` 델리게이션)
│ ├── State.luau # 캐시만 하는 non-owning 핸들, state(state) 분기, `:With`/`:Compute`/`:Observer`(등록 즉시 1회 실행)/`:Gate`(`GateNode`, `ComputeNode`와 같은 층위 — `base/gate-plan.md`) 전부 여기 소속
│ ├── Observer.luau # ⭐ [2026-08-25 신설, 7라운드 `H-99`] `Observer` 객체와 **`:Subscribe()`/`:WeakSubscribe()` 전역 레지스트리의 소유 모듈** — `EpochMap.luau`와 같은 이유로 `State.luau`에 묻지 않는다(`Effect`/`Gate`/leaf 핸들러가 전부 이 레지스트리를 본다)
│ ├── EpochMap.luau # 재사용 가능한 Epoch 부기 객체(`:Update`/`:Refresh`/`:Sync`/`:TrackFrom`) — `State.luau`에 묻지 않고 별도 모듈, `GateNode`/`State`/`Effect`가 전부 씀(`base/state-epoch-plan.md`)
│ ├── Store.luau # source 집합체, dot-access로 Source 그대로 반환
│ ├── Store.luau # source 집합체, dot-access로 Source 그대로 반환(**평범한 레코드 필드** — 타입 함수 안 씀). **[2026-08-25]** 생성은 **명시적 초기화**(타입 인자에 `Source<T>` 직접, `defaults`에도 `Source(v)` 직접 — 옛 lazy `__index` 폐기), 동적 키는 `:Of<<T>>(name)` 하나(옛 `GetDynamic` 흡수), `:Names()`, 예약 키 진단용 `CheckReserved`(`base/store-plan.md`)
│ ├── Blocker.luau # 값 기반 emit 지연/합치기(`base/blocker-plan.md`) — 위 `state:Gate``GateNode` 위에 얹히는 **정책**, 바닥부터 짜지 않음
│ ├── Modifier.luau # flatten-before-dispatch, immutable 체이닝, 제네릭 `__index` 필드 setter 합성 + `:Apply`/`:Peek`/`Overridden`(`base/modifier-plan.md`)
│ ├── Tag.luau # 값 타입+immutable clone 체이닝(`Tag(...)`/`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`/`:Names`) — 참조 카운트 Handler는 Dispatch/Tag.luau(아래), 엔진 호출은 주입된 addTag/removeTag(`base/tag-plan.md`)
@ -316,6 +317,53 @@ existing-instance-bind는 **[2026-08-14 세션] 기각되어 `archive/`로
확정을 막던 항목도 아니었음(`purity-and-effects-plan.md`/`tween-plan.md`는
이미 `base/`로 승격 완료).
## error 계약 — `level` 이분과 메시지 언어 (2026-08-25 확정, 7라운드 `H-104`/`H-105`)
quad가 던지는 error 자리는 약 29곳이고(`base/` 전수), **쓰기 전에 정해두면
한 번에 맞고 나중에 바꾸면 전수를 다시 훑어야 한다.**
- **`level` 이분** — *즉시 error*가 quad의 주 방어선인데, 지금까지 **한
군데도 `level` 인자가 없어** 전부 quad 내부 줄을 가리켰다. 사용자는 자기
코드 어디서 틀렸는지를 볼 수 없었다.
| 종류 | `level` | 예 |
|---|---|---|
| **사용자 입력 검증** | **2**(호출부를 가리킴) | deps 타입/`nil`, 이중 바인드, 예약 키, 요소 타입 |
| **내부 불변식 위반** | **1**(그 자리를 가리킴) | 부기가 깨짐(`lengthList[i] == nil` 등) |
```lua
error("Effect: dep #3 is not a State/Source/Ref", 2)
error("Dispatch.getOffsetAt: lengthList[3] is nil — bookkeeping is broken", 1)
```
내부 불변식 위반은 곧 **quad 자신의 버그**라, 호출부가 아니라 터진 자리를
가리켜야 리포트가 쓸모 있다.
- **⭐ 메시지는 영어로 통일한다**(**사용자 확정**, 2026-08-25). 지금
코퍼스는 영어 6 / 한국어 약 23으로 이미 갈려 있고, 공개 표면인데
정해진 적이 없었다. `.claude/conventions.md`의 *"사용자가 보게 될 것은
한국어"*는 **이 프로젝트의 대화·문서** 규칙이고 **quad 라이브러리
사용자**에게까지 적용된다고 정해진 적이 없다 — 여기서 정한다. 이미
영어인 6곳(동적 경로 가드 4형제, 모듈 초기화, attribute 이름 충돌)이
핵심 경로라는 것도 같은 방향이다. `base/`의 예시 메시지도 영어로 쓴다.
## 예외 안전성 계약 — 감싸지 않는다 (2026-08-25 확정, 7라운드 🅒)
**예외가 나면 그 파동/그 자리의 부기는 복구되지 않는다.** `pcall`
감싸는 자리는 하나도 두지 않는다.
- 2026-08-21에 `base/slot-plan.md``materializeSlotTree`에 대해 내린
판단(*"마운트 도중 예외는 quad가 복구를 보장하지 않는 상태이고 … 아직
실제로 밟은 적 없는 경로다 … 실제로 물리면 그때 넣는다"*)을 **전 자리로
확장**한 것이다. `.claude/conventions.md`의 *"드문 오용이나 가상의 미래
요구까지 방어/최적화하려고 구조를 복잡하게 만들지 않는다"* 원칙 그대로.
- **적용 자리 넷**(7라운드가 찾은 것): State 전파 루프(구독자당 hot path),
게이트 flush + `Blocker:Off()` 순회, `Dispatch.drive`의 배치 게이팅,
Dispatch 체인 슬롯. 각각의 실패 모드는 그 문서들이 적는다.
- **같은 원칙이 `EffectHandle:Rerun()`에도 적용된다**`fn`이 던지면
`_running`이 참으로 남는 것까지 포함해 복구하지 않는다(사용자: *"에러가
난 이후 데이터의 무결이 깨져도 별 책임 안 진다는 quad의 일반 동작"*).
- **yield 금지와 같은 톤으로 사용자 문서에 명시할 것.**
## 코드 스타일 — 네이밍 케이싱 (2026-08-08 두 번째 세션 신설)
지금까지 각 문서가 예시 코드를 쓰며 암묵적으로 따라온 패턴을 사용자가
@ -442,15 +490,20 @@ CollectionService 태그 등)를 흉내낼 필요가 없고, quad-base 자체
## Store/State/Source 온톨로지 — 확정됨 (요약)
Store는 source(실제 값이 존재하는 단일 지점) 집합체이고, `store.key`
접근하면 이미 만들어져 있는 Source 객체를 그대로 반환하거나(defaults로
Store 생성 시 미리 만들어둔 경우), 아직 없으면 그 자리에서 만들어 저장한
뒤 반환한다(별도 wrapper 없음 — **[2026-08-06 후속 세션 정정, 2026-08-07
추가 정정]** 원래 "매번 새 State를 감싸 반환"이었으나, `Source`가 구조적으로
`State`를 만족하도록 재구성되며 wrapper 계층 자체가 불필요해짐. 이후
"Store 생성 시 전부 eager하게만 만들어진다"로 한 차례 더 정리됐다가, Luau
타입이 런타임에 강제되지 않아 defaults 없이 만든 키를 나중에 `:Set()`하면
크래시난다는 점이 지적돼 lazy `__index`+저장 생성도 같이 필요함이 확인됨 —
상세는 `base/source-state-plan.md` "Source가 State를 만족함" 절). 전파는
접근하면 **그 키의 Source 객체를 그대로 반환한다** — 별도 wrapper도,
프록시도, **타입 함수도** 없다(`Source`가 구조적으로 `State`를 만족하도록
재구성되며 wrapper 계층 자체가 불필요해짐 — `base/source-state-plan.md`
"Source가 State를 만족함" 절).
**⭐ [2026-08-25] 두 가지가 확정됐다** — (1) 생성은 **명시적 초기화**다:
타입 인자에 `Source<T>`를 직접 쓰고 `defaults`에도 `Source(v)`를 직접
넣는다. 옛 lazy `__index`(없는 키를 그 자리에서 만들어 저장)는 폐기됐고,
그래서 `defaults`가 곧 선언 키 집합이라 `store:Names()`가 성립한다.
(2) 레코드 필드 타이핑에 **타입 함수를 안 쓴다**
(`WrapStore`/`ProcessStoreType` 폐기). 쓰기는 `store.key = v`가 아니라
**`store.key:Set(v)`**이고, 동적 키는 `store:Of<<T>>(name)` 하나다.
상세는 `base/store-plan.md`가 소스 — 같은 날 "`store.key`를 값으로"
재설계를 넣었다가 철회한 경위는
`archive/store-value-field-redesign-withdrawn.md`. 전파는
push-invalidate(신호만)/
pull-recompute(`Get()` 시점) — Fusion식 eager 노드 없이도 다이아몬드
의존성 중복 재계산 문제가 풀림(**[2026-08-14 보강]** 푸는 주체는

View file

@ -260,6 +260,17 @@ end
- **`nameClaims`와의 순서**: 위치 claim이 **먼저**다 — 같은 그룹의 두 번째
위치는 이름 claim까지 갈 것 없이 그 자리에서 거부되어야 하고, 그래야
`nameClaims`에 절반만 기록되는 중간 상태가 안 생긴다.
- **⚠️⚠️ [2026-08-25 신설, `/code-review high`] GC 계약 — 그룹 값
객체(`groupValue`)는 `inst`를 되참조하면 안 된다.** 이 값은
`groupClaimKeys`의 **내부 키**로 쓰이는데, `Relate`의 내부 키는
`SetStrong`/`SetWeak` **어느 쪽이든 항상 강하다**(Luau엔 ephemeron이
없다 — `base/relate-plan.md`의 "위험한 패턴" 절 슬롯 표). 그래서 값이
`inst`로 되돌아가면 `buckets`의 weak 키가 자기 버킷을 통해 살아남아
**그 `inst`가 영원히 안 죽는다.** 실제로 밟기 어려운 모양은 아니다 —
같은 트리의 `Ref`가 그 `inst`로 채워진 뒤 그 Store에 담기면 된다.
**되참조가 필요해지면 내부 키를 값 객체가 아니라 이름/토큰으로
바꿔야 한다**(값 객체는 그때 값 슬롯으로 내려가고, 그 슬롯은
`SetWeak`으로 도망갈 수 있다). 지금은 그 요구가 없어 계약으로 둔다.
- 이걸로 `Frame { a, a }` 갭(구 `AT-11`/5라운드 `AT-2`)도 **같이 닫힌다.**
- **`Tag`는 왜 다른가**: `Tag`는 같은 객체를 여러 위치에서 재사용하는 게
**정상 관례**이고 위치(`k`) 기준 참조 카운트로 안전하다(`base/tag-plan.md`)
@ -354,6 +365,12 @@ Attribute(store1, store2, ..., {plain = "table도 됨"}) -- 생성자, 여러
Attribute.Merged(a, b, ...): Attribute -- 합성, 이름이 겹치면 error
Attribute.Overridden(a, b, ...): Attribute -- 합성, 이름이 겹치면 조용히 뒤가 이김
attr:NameMap(): {[string]: Source<any>} -- 평탄화된 이름→Source 맵(아래 "메커니즘" 절이 쓰는 것)
-- ⭐ [2026-08-25, 7라운드 `H-79`] `:NameMap()`이 Store를 평탄화하려면 **Store가
-- 자기 키 집합을 알아야 한다** — 그 표면이 없어서 키 집합이 접근 이력에
-- 좌우되고 있었다(lazy `__index` 시절엔 0개/1개/2개로 갈렸다). Store가
-- **명시적 초기화**로 바뀌며 `defaults`가 곧 선언 키 집합이 됐고,
-- `store:Names()`가 그걸 준다(`base/store-plan.md`). `:NameMap()`은 각
-- Store에 대해 `store:Names()`를 돌며 `store[name]`을 모은다.
-- [명시 추가, 2026-08-20 구현 전 QA 4라운드] 생성자 자신의 이름 겹침 정책:
-- Attribute(a, b)에서 a와 b가 같은 이름을 가지면 **뒤에 온 인자가 이긴다**
-- (= Overridden과 같은 정책, error 아님). Merged의 "겹치면 error"는 그 함수만의

View file

@ -48,7 +48,9 @@ Signal 미채택, Ref 역할)과 소스 트리 상 패키지 경계(디스패치
`:Subscribe()`/`:Unsubscribe()`, 이중 바인딩 금지 게이트 →
**`base/source-state-plan.md`**.
- **Store** — 이름 붙은 Source 모음, `defaults`와 eager/lazy 생성, `store.key`
dot-access 타이핑(+Luau `type function`), `store.key:Set(value)` 문법,
dot-access 타이핑(**[2026-08-25]** `type function` 합성은 폐기 —
타입 인자에 `Source<T>`를 직접 써서 **평범한 레코드**로 짓는다,
`base/store-plan.md`), `store.key:Set(value)` 문법,
"Store가 Store를 저장 가능한가", Store 부작용 정책 → **`base/store-plan.md`**.
- **`Ref` / `PreRef`** — 용도 재정의, `.Value`/`:Set`/`:Callback`/`:Wait` API,
`Ref`의 retract, PreRef 호이스팅/1회용 가드 → **`base/ref-plan.md`**.
@ -239,10 +241,12 @@ Service` 기반으로 구현)로 두면 됨 — 별도 `On` 모듈/필드 접근
**Store 쪽 dot-access는 그대로 유지**: `store.key`는 실질적으로 Luau가
타입을 좁혀주는 이득이 있어서(Store 자체가 `{key: Source<number>, ...}`
평범한 레코드 타입으로 지어짐, `base/store-plan.md`) 그대로 유지.
**[2026-08-25]** 그 레코드를 **타입 함수로 합성하던 것만** 폐기됐다 —
지금은 타입 인자에 `Source<T>`를 직접 쓴다.
**[정정, 2026-08-18] `store "key"` 문자열 커링은 기각됐다** — 여기 폴백으로
같이 적혀 있었으나 폐기됨(`"a"`가 그냥 `string`으로 들어가 `Source<T>`
`T`를 알 수 없고, dot-access + `type function` 타이핑이 자리잡아 더 이상
필요 없어짐). 동적 키는 명시적 `store:GetDynamic<<T>>(name)`으로 간다
필요 없어짐). 동적 키는 명시적 `store:Of<<T>>(name)`으로 간다(**[2026-08-25]** 옛 이름 `GetDynamic`)
`base/store-plan.md`가 소스.
**이벤트가 이 관습의 예외인 성격도 바뀜** — "타입을 포기하는 예외"가 아니라
**이름 지정 방식만 문자열 키인 예외**다(타입은 위 정정대로 생성기가 준다).

View file

@ -59,7 +59,7 @@ blocker:IsOn() -> boolean -- [2026-08-18 신설] `self.IsBlocked`를
state:Block(blocker) -> state -- 새 gated state 반환. **호출되는 즉시**(나중에
-- 처음 블록될 때가 아니라) onunblock 핸들을
-- blocker의 weak 배열에 등록.
-- blocker의 weak-키 셋에 등록(아래 "onunblock 핸들 보관").
blocker:Policy(emit) -> onUpstreamEmit
-- [2026-08-24 신설] 이 blocker의 게이트 정책을
-- **값으로** 돌려준다. `state:Block(b)`
@ -98,6 +98,30 @@ gated state의 동작:
`blocker.IsBlocked`를 보고 `emit()`을 부를지 `HasBlockedEmit`만 세울지
정한다. `Debounce`/`Throttle`도 같은 자리에 다른 정책으로 들어간다.
**⭐⭐ [2026-08-25 신설, 7라운드 `H-63`] onunblock 핸들 보관 — 세 자리를
정한다.** 지금까지 *"blocker의 weak 배열"*이라고만 적혀 있었고 셋이
비어 있었다.
1. **자료구조는 weak-키 해시맵 셋** `{[handle] = true}`(`__mode = "k"`)이다.
값-weak **배열**이면 항목이 수거될 때 구멍이 생겨 `ipairs`가 첫 구멍에서
멈추고(`#`도 border 미정) **뒤의 살아있는 게이트가 `Off()`를 못 받는다.**
`H-7``Ref.Callbacks`를 배열에서 해시맵 셋으로 바꾼 것과 같은 문제이고
같은 처방이다.
2. **⭐ 핸들을 강하게 드는 주체는 정책이 반환하는 `onUpstreamEmit` 클로저다** —
그 클로저가 onunblock 핸들을 upvalue로 잡는다. 체인은
`GateNode → onUpstreamEmit → onunblock 핸들`이고, Blocker 쪽은 weak이라
**게이트 노드가 죽을 때만** 수거된다. 이게 안 정해져 있으면
`Debounce``setup`을 문서 그대로 짰을 때 핸들이 다음 GC에서 사라져
`b:Off()`**조용히 아무것도 안 한다**(디바운스가 영영 안 나감) —
실패가 GC 타이밍에 따라 간헐적이라 나중에 잡기 제일 어려운 종류다.
- **Blocker가 강하게 드는 안은 기각** — 오래 사는 Blocker 하나가 거기
걸렸던 **모든 gated state와 그 상류 체인을 영원히 살려둔다**(`:List`
항목마다 게이트를 무는 패턴에서 직행 누수).
3. **`Off()`/`OffWithoutEmit()`은 스냅샷을 뜬 뒤 순회한다** — 순회 중
새 등록(핸들 → flush → 하류 Observer가 `state:Block(b)`를 새로 만듦)이
`pairs`에서 미정의이기 때문. `Ref.Callbacks`/State 구독자 집합과 같은
처방(`base/source-state-plan.md`의 `H-23` 확정).
**⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-33`/`H-49`] 그 정책을 값으로
꺼내는 표면 `blocker:Policy(emit)`을 추가한다** — 표면이 하나 늘고,
`Blocker()` 생성자와 `state:Block`은 그대로다.
@ -111,7 +135,15 @@ gated state의 동작:
풀 때 그 `emit`이 정확히 1회 불린다.
- **`Debounce`/`Throttle`은 Blocker를 사적으로 하나 갖는다** — 적용 핸들당
하나(커링 결과가 여러 곳에 적용될 수 있으므로 `Apply` 시점 생성).
`pending` 같은 상태는 별도로 안 들고 `HasBlockedEmit`으로 흡수한다.
`pending` 같은 상태는 별도로 안 든다.
**⭐⭐ [2026-08-25 정정, 7라운드 `H-86`] "`HasBlockedEmit`으로 흡수한다"는
성립하지 않았다.** 아래 *"`HasBlockedEmit`은 게이트 흡수 집합의 특수형"*
절이 그 값을 **게이트 노드의 `withheld`로** 흡수해버려, `Blocker` 객체
쪽엔 **실체가 없다**. 두 흡수가 반대 방향이라 결과적으로 **아무도 안 들고
있었다** — 정책이 "지금 쌓인 게 있나"를 읽을 통로가 0개였고,
그래서 `Throttle`의 창이 idle로 못 돌아왔다. **통로는
`emit(commit: boolean?) -> boolean`의 반환값**이다
(`base/gate-plan.md` 2번).
상세와 의사코드는 `base/gate-plan.md`의 5번 항목이 소스.
**`:Get()`엔 영향 없음** — 블록은 emit **전파**만 지연시킨다. 블록 중이라도

View file

@ -57,18 +57,31 @@ State는 `:With`/`:Compute`로 만들어진 파생값일 수 있어 쓰기가
의미 있음 — **사용자 확정**("맞음. 확실해"). 이 원칙 자체는 그대로 유지되고,
아래 3번의 구체적 메커니즘만 2026-08-06 후속 세션에서 더 단순하게 갱신됨.
### 3. Store는 내부 Source를 그대로 반환 — Source가 State를 구조적으로 만족
### 3. Store는 내부 Source를 `store:Of(key)`로 준다 — Source가 State를 구조적으로 만족
> **[2026-08-25] 이 절의 결론은 유지된다.** 같은 날 오전에
> "`store.key`가 값이고 `store:Of(k)`가 프리미티브"라는 재설계를 넣었다가
> **같은 날 철회**했다 — `archive/store-value-field-redesign-withdrawn.md`.
> 바뀐 것은 **생성이 명시적 초기화가 됐다는 것 하나**다(아래 ⭐).
**확정**: `Source<T>`가 구조적으로 `State<T>`를 만족하므로(단방향 호환,
Svelte `Writable<T> extends Readable<T>`와 같은 모양), `store.key`는 Store
생성 시 이미 만들어둔 진짜 Source 객체를 그대로 반환한다 — 별도 프록시
타입도, 별도 캐싱 계층도 없음(Source 자체가 이미 State의 읽기 계약을
전부 만족하고 거기에 `:Set(value)`/`:Emit()`이 추가로 있을 뿐이라 "원본이라
쓰기 가능"이라는 위 2번 규칙과도 자연히 맞아떨어짐). 쓰기 문법도 같이
바뀜: `store.key = v`가 아니라 `store.key:Set(v)`(레코드 타입 읽기/쓰기
대칭 + lazy 동작에 `=`가 암시하는 "즉시 커밋"이 안 맞는다는 논거). 상세
근거·타입 설계·Luau 솔버 검증 필요 항목은 `base/source-state-plan.md`
"Source가 State를 만족함" 절이 최종 소스.
Svelte `Writable<T> extends Readable<T>`와 같은 모양), **`store.key`
진짜 Source 객체를 그대로 반환하는 평범한 레코드 필드다** — 별도 프록시
타입도, 별도 캐싱 계층도, **타입 함수도** 없다(Source 자체가 이미 State의
읽기 계약을 전부 만족하고 거기에 `:Set(value)`/`:Emit()`이 추가로 있을
뿐이라 "원본이라 쓰기 가능"이라는 위 2번 규칙과도 자연히 맞아떨어짐).
쓰기는 `store.key = v`가 아니라 `store.key:Set(v)`
(`base/store-plan.md`의 "Store 값 설정 문법" 절).
- **⭐ [2026-08-25] 생성은 명시적 초기화다** — 타입 인자에 `Source<T>`
직접 쓰고 `defaults`에도 `Source(v)`를 직접 넣는다. 옛 lazy `__index`
(없는 키를 그 자리에서 만들어 저장)는 폐기됐고, 그래서 **선언 키 집합의
런타임 소스가 하나**가 된다. 부모가 값을 다 안 넘겨도 되게 하려면
**컴포넌트가 자기 `DEFAULTS`로 채워** 넘긴다.
- 동적 키는 `store:Of<<T>>(name)` 하나다(옛 `GetDynamic` 흡수).
상세 근거·타입 설계는 `base/source-state-plan.md`
"Source가 State를 만족함" 절과 `base/store-plan.md`가 최종 소스.
**[이전에 확정했다가 폐기된 `StoreSource` 프록시 설계는 이 결론으로
완전히 대체됨 — 원문·역전 이유·신구 비교표는
@ -327,7 +340,8 @@ Ref를 받으면 그냥 전부 실행하면 됨 — Ref 콜백 리스트는 애
(`Modifier.Overridden`은 2026-08-08 세션에서 이름 확정, 이 목록에서
빠짐). (`GetSource` 계열 접근자는 위 3번 정정으로 아예
불필요해짐 — `store.key`가 직접 Source를 반환하므로 별도 접근자 자체가
없음.) `base/bind-system-plan.md`의 "남은 열린 질문" 절(정확한 함수/
없음. **[2026-08-25]** 단 **동적 키** 전용 창구 `store:Of<<T>>(name)`
있다 — 옛 이름은 `GetDynamic`이었다.) `base/bind-system-plan.md`의 "남은 열린 질문" 절(정확한 함수/
생성자 이름 미정)과 같은 급의 후순위 항목 — 구현 단계에서 다른
이름들과 함께 확정.
- **`quad2-try`는 확인 불필요로 재확인** — 진행이 중단된 상태라 이 논의와

View file

@ -833,14 +833,35 @@ function clearTimeout(timeout: Timeout) timeout._native() end
>
> - `gate:passThrough()`**`b:Off()`**(보류분 1회 방출), 그 뒤 다시 `b:On()`.
> - `Trailing = false` 경로 → **`b:OffWithoutEmit()`**.
> - **`pending`은 없앤다** — "보류된 게 있는가"는 Blocker의 `HasBlockedEmit`
> - **`pending`은 없앤다** — "보류된 게 있는가"는 게이트 노드의 흡수 집합
> 이미 들고 있다(중복 상태를 안 만든다). 이게 `H-32`를 구조적으로 없앤다.
> - **⭐⭐ [2026-08-25 정정, 7라운드 `H-86`] 읽는 통로는 `HasBlockedEmit`
> 아니라 `emit`의 반환값이다.** 여기 한때 *"Blocker의 `HasBlockedEmit`
> 이미 들고 있다"*고 적었는데, `base/blocker-plan.md`가 그 값을 **게이트
> 노드의 `withheld`로** 흡수해 `Blocker` 객체 쪽엔 실체가 없다 — 정책이
> 읽을 방법이 **0개**였다. 그대로 짜면 창을 닫을 조건을 못 읽어
> **창이 영원히 열린 채**가 되고, `Throttle`의 정의(*"첫 신호는 즉시
> 통과"*)가 사라져 사실상 "항상 trailing"이 되며 타이머 체인이 자기를
> 무한 재무장해 아래 8절의 "유계 GC" 분석도 깨진다(실측 대조 확인).
> - **확정된 통로**: `setup`이 받는 `emit`
> `emit(commit: boolean?) -> boolean`이 되어, 반환값이 **"실제로
> 내보내거나 버릴 게 있었는가"**를 준다(`base/gate-plan.md` 2번).
> `onWindowEnd``if not emit() then window = nil else rearm() end`,
> `MaxTime` 재무장 조건도 같은 반환값으로 판정한다.
> - **버리는 경로도 같은 핸들이다**`Trailing = false`/`Cancel`은
> `b:OffWithoutEmit()`만으로는 집합이 안 비므로 `emit(false)`가 필요하다
> (`H-55`).
> - `Flush`/`Cancel` 핸들은 `setup` 클로저 안에서 `b`를 캡처해 만든다 —
> 노드 객체 참조가 필요 없다(`Blocker`가 onunblock 핸들을 등록하는 방식과
> 같은 우회).
>
> 아래 코드는 **창/타이머 정책 자체의 참고용**으로만 읽을 것 —
> `openWindow`/`onWindowEnd`/`MaxTime`의 분기 구조는 그대로 유효하다.
> **[2026-08-25 추가, 7라운드 `H-94`] 팩토리를 `setmetatable`+`__call`로
> 만드는 표기도 이 배너 범위에 들어간다** — `__call` 테이블은 `:Apply`
> 함수 타입 자리에 **안 들어간다**(실측). 확정된 형태는 **지정된 필드**로
> 자기를 노출하는 것이다(`base/source-state-plan.md`의
> "`state:Apply(factory)`" 절).
**⭐ [2026-08-24 `H-32`] 같이 고쳐야 할 논리 결함 하나 — `Trailing = false`에서
`pending`이 영구히 참으로 남는다.** 아래 코드는 창이 열려 있는 동안 오는 신호를
@ -887,8 +908,20 @@ local function makeGate(reset: boolean, opts)
for gate in instances do gate._cancel() end
end
-- 팩토리 자체 — :Apply(factory)가 호출할 수 있는 함수이면서, 동시에
-- 전체 브로드캐스트 :Flush()/:Cancel()도 갖는 콜러블 객체(5-4절)
-- 팩토리 자체 — :Apply(factory)가 쓸 수 있으면서, 동시에
-- 전체 브로드캐스트 :Flush()/:Cancel()도 갖는 객체(5-4절)
--
-- ⛔⛔ [2026-08-25 폐기, 7라운드 `H-94`] **`__call`은 안 쓴다.**
-- `luau-analyze` 실측에서 `__call` 테이블은 `(State<T>) -> U` 함수 타입
-- 자리에 **안 들어간다**(제네릭·비제네릭 양쪽) — 런타임은 멀쩡하고
-- 타입만 막히므로 `--!nocheck` 스파이크에선 안 드러난다. 타입 레벨
-- `__call``self`도 못 받는다. **확정된 형태는 `__call`이 아니라
-- 지정된 필드**로 자기를 노출하는 것이고, `Debounce`/`Throttle`/
-- `Blocker`가 전부 같은 계약을 만족한다 —
-- `base/source-state-plan.md`의 "`state:Apply(factory)`" 절이 소스
-- (필드 이름과 정확한 시그니처는 구현 시 정한다).
-- 아래 `__call` 표기는 **창/타이머 정책 본문을 읽기 위한 자리표시자**로만
-- 볼 것 — 위 7절 배너와 같은 취급이다.
local factory = setmetatable({}, {
__call = function(_, self)
local gate = Gate(self) -- Blocker가 쓰는 것과 같은 게이트 노드

View file

@ -983,6 +983,18 @@ function Dispatch.process(inst, k, v, index)
list[index] = { handler = h, retractor = retractor }
end
end
--[[
⚠️ [2026-08-25 신설, 7라운드 `H-103`] `h.process`가 던지면 그 자리에 `NOOP`
마커가 **영구히 남는다** — 그 자리의 정리가 통째로 사라지고, 명시적 철거로도
회수되지 않는다(`retractFrom`이 `NOOP`을 부르면 아무 일도 안 한다). 예컨대
`AttributeKeyHandler.process``nameClaims:SetStrong` **뒤에** `setAttribute`
부르므로, 주입 op가 미주입 에러 스텁이면 **이름 claim만 남고 해제 경로가
없는** 상태가 된다.
**`pcall`로 감싸지 않는다** — `base/architecture.md`의 "예외 안전성 계약" 절이
소스다. 자리당 hot path이고, 예외 이후의 부기 무결성을 quad가 보장하지
않는다는 일반 계약을 여기에도 그대로 적용한다. **실제로 물리면 그때 넣는다.**
]]
function Dispatch.retractFrom(inst, k, index)
-- index부터(포함) 끝까지, 꼬리(가장 깊은 인덱스)부터 역순으로 정리.
@ -1664,6 +1676,11 @@ function Dispatch.getOffsetAt(ownerKey, at)
end
local cur = bk.offsetCache[bk.invalidAfter]
for i = bk.invalidAfter, at - 1 do
-- ⭐ [2026-08-25, 7라운드 `H-106`] `nil` 가드 — `recompute`만 갖고 있던
-- `C-6` 진단이 이 경로에선 우회돼 익명 산술 에러로 먼저 터졌다.
if bk.lengthList[i] == nil then
error("Dispatch.getOffsetAt: lengthList[" .. i .. "]가 nil — bookkeeping is broken", 1)
end
cur += contribution(bk, i) -- lengthList[i](State면 :Get())
bk.offsetCache[i + 1] = cur -- **지금 자리의 길이가 다음 자리의 offset을 정한다**
end
@ -1729,7 +1746,19 @@ local function recompute(ownerKey, bk)
-- 요소가 하나도 없는 Slot(`Slot()` 직후, 데이터가 빈 `:List` 등)은 `N``nil`
-- 채로 `materializeSlotTree` 끝의 recompute에 도달한다 — `for i = 1, nil`
-- 그 자리에서 터진다. 빈 Slot은 완전히 정상적인 상태라 이건 방어가 아니라 계약.
for i = 1, bk.N or 0 do
-- ⭐⭐ [2026-08-25 재작성, 7라운드 `H-101`] 재진입 차단 + 되감기.
-- (a) 자기 전용 `recomputeBlocker`를 켜 재진입 recompute를 막는다
-- (배치 게이팅용 Blocker와 **별개 객체** — 합치면 배치 `Off()`
-- onunblock 순회 도중 같은 Blocker가 다시 꺼져 핸들이 재귀한다.
-- `base/blocker-plan.md`가 네스팅을 의도적으로 미지원한다).
-- (b) 상한 `bk.N`을 **매 반복 재평가**한다 — 진입 시 한 번만 평가하면
-- 재진입이 끝에 붙인 자리를 바깥 루프가 아예 안 본다.
-- (c) `bk.invalidAfter`가 낮아지면 **그 지점 다음부터 되감는다**.
-- 접두합을 남겨두면 되감기 지점의 `sum`이 공짜로 복원된다.
bk.recomputeBlocker:On()
local prefix, i = {}, 1
while i <= (bk.N or 0) do
prefix[i] = sum
local offset = bk.sourceList[i]
-- offset은 실제 Source이거나 None(발행 채널 없음) — None은 truthy라
-- `if offset then`만으로는 안 걸러짐, 명시적으로 배제해야 함.
@ -1742,18 +1771,83 @@ local function recompute(ownerKey, bk)
error("Dispatch.recompute: sourceList[" .. i .. "]가 nil — 부기가 깨졌음(계약상 None이어야 함)")
end
local abs = Dispatch.getOffsetAt(ownerKey, i) -- 절대 offset(캐시 경유)
bk.invalidAfter = i -- 여기까지 유효해짐
if offset ~= None and offset:Get() ~= abs then -- 실제로 다를 때만 Set
offset:Set(abs)
offset:Set(abs) -- ← 사용자 코드가 돌 수 있는 자리
end
local v = bk.lengthList[i]
sum += (if isState(v) then v:Get() else v)
if bk.invalidAfter < i then -- 누군가 낮췄다 되감기
-- ⭐ [2026-08-25] `+1`이 아니라 **그 자리부터** 다시 돈다.
-- `prefix[j+1]`**옛** `lengthList[j]`로 누적된 값이라, 길이가
-- 바뀐 자리를 건너뛰면 `sum`이 낡은 채 `Length`에 실린다
-- (재진입은 블로커에 막혀 자가치유도 안 된다). `j`를 다시 돌아도
-- offset 쓰기는 바로 위 `~=` 가드가 막아 no-op다.
i = bk.invalidAfter
sum = prefix[i]
else
i += 1
end
end
-- ⭐ [2026-08-25] 캐시 리셋과 블로커 해제를 **`Length:Set` 앞에** 둔다.
-- `Length:Set`은 상위 owner의 사용자 코드를 돌릴 수 있는데, 그 도중
-- 낮춰진 `invalidAfter`를 뒤에서 무조건 덮으면 **캐시가 낡은 채로
-- "유효"로 표시**되고, 그때 불린 `gatedRecompute`는 블로커가 아직
-- 켜져 있어 조기 반환했으므로 아무도 다시 안 돈다.
bk.invalidAfter = bk.N or 0
bk.recomputeBlocker:OffWithoutEmit()
if isSlot(ownerKey) and ownerKey.Length:Get() ~= sum then
ownerKey.Length:Set(sum) -- **Length엔 base를 안 더한다** — 길이는 위치와 무관
end -- (`base/slot-plan.md`의 "Slot-in-Slot 중첩" 절)
end
```
**⭐⭐ [2026-08-25 신설, 7라운드 `H-101`/`H-102`] 재진입과 되감기.**
- **재진입 경로는 실재한다**`offset:Set(abs)`가 **동기 전파**라 그
offset을 관측하는 사용자 코드가 그 자리에서 같은 owner를 건드릴 수
있다. 그러면 배치 게이팅 Blocker가 꺼진 정상 상태에서 **재진입
`recompute`가 완주해 올바른 값을 써놓고, 바깥 루프의 꼬리가 자기
`sum`으로 그걸 덮는다.** 실제 재현에서는 `bk.N`이 자란 경우가
걸렸다(바깥은 옛 상한까지만 돌아 `sum`이 모자랐다).
- **원문이 든 트리거는 틀렸다**`:List``updateFn`은 offset 변경으로
**재실행되지 않는다**(offset을 State로 넘겨 관측하게 할 뿐이다).
남는 트리거는 그보다 좁은 것 하나 — **`slot.Offset` State를 관측하는
사용자 코드**.
- **`sum`이 낡는다는 서술도 대체로 틀렸다** — `contribution`
`offset:Set` **직후**에 읽으므로, 그 Set이 유발한 자식 길이 변경은
이미 반영된 값이다(동기 계약 하에서).
- **왜 되감아야 하나** — 앞자리가 당겨지는 splice가 도중에 나면 순차
순회로는 복구가 안 된다. **사용자 예시**: *"`{a,a,a, b,b, c,c}` 여기서
a,a 두개가 소멸했는데, 이미 c 에 왔다면, b,b 가 c,c 로 덮여지고 a,a 는
달라지는게 없을 가능성이 생기죠. 따라서 recompute 도중 변경이 생긴다면,
변경이 생긴 곳으로 위로 올라가야할것 같습니다."*
- **되감기 신호는 `bk.invalidAfter` 하나로 통일한다** — 새 필드를 안
만든다. 두 뜻("캐시가 여기까지 유효"와 "여기 다음부터 다시 해야 함")이
실제로 같은 것이기 때문이고, `getOffsetAt`도 이미 `for i = bk.invalidAfter,
at - 1`로 그렇게 읽는다.
- **⭐ [2026-08-25 정정, `/code-review high`] 재개 지점은 `invalidAfter`
자신이다(`+1` 아님).** 한때 *"길이가 바뀐 자리의 자기 offset은 여전히
유효하다"*를 근거로 `+1`로 적었는데, **offset은 유효해도 `sum`
아니다** — `prefix[j+1]`**옛** `lengthList[j]`로 누적된 값이라 그
자리를 건너뛰면 낡은 합계가 `ownerKey.Length`에 실리고, 재진입은
블로커에 막혀 있어 **자가치유도 안 된다.** `j`를 다시 도는 비용은
offset 쓰기 하나인데 그건 `offset:Get() ~= abs` 가드가 막아 no-op다.
- **그래서 splice도 `j - 1`이 아니라 `j`로 낮춘다** — 재개가
`invalidAfter`니까 `j`가 곧 "그 자리부터 다시"다. `getOffsetAt`의 캐시
의미(`offsetCache[j]` = 1..j-1의 합)도 splice/길이변경 어느 쪽에서든
`j` 이전은 안 바뀌므로 그대로 성립한다.
- **`H-102`(splice가 observer를 옮겨도 클로저에 박힌 인덱스는 안 고쳐진다)가
이걸로 같이 닫힌다** — 아래 `setLength``gatedRecompute`가 **인덱스를
캡처하지 않고 조회**한다. `slot._elemIndex`(물리 요소 → 인덱스 역방향
맵, 6라운드 `H-39`)와 같은 개념을 **Dispatch 층위로 격상**해 `bk`
소유하고, splice가 배열을 당길 때 같이 갱신한다 — 그래서
`base/slot-plan.md`의 splice 요구 목록에 항목이 늘지 않는다.
(사용자: *"그것을 dispatch 로 격상시키는게 더 나아보이는 지점"*.)
`bk.invalidAfter = 0`으로 뭉개는 안은 기각 — *"0 으로 두면, 모든 부분에
있어 캐시가 무관해져요"*.
**`offset`/`sum`은 0-based *개수*이지 Lua 배열 인덱스가 아님(2026-08-11
세션 명시화).** Luau/Lua 배열은 1-based 관례지만, 여기서 계산하는
`offset[i]`는 "그 앞에 몇 개가 있는가"라는 순수 카디널 수라 자연스럽게
@ -1804,15 +1898,37 @@ function Dispatch.setLength(ownerKey, i, len, anchor)
bk.lengthList[i] = len
bk.N = math.max(bk.N or 0, i) -- [2026-08-18 3라운드] N 수명주기 — "저장 위치" 절 참고
-- ⭐ [2026-08-25] 이 자리의 유일한 토큰 — 아래 클로저가 인덱스 대신 이걸 캡처한다.
local token = bk.tokens[i]
if token == nil then
token = {}
bk.tokens[i] = token
end
bk.indexOfToken[token] = i
-- [2026-08-24 `H-3`] 접두합 캐시를 여기까지 당긴다 — `i` 자리의 offset은
-- `1..i-1`의 합이라 안 바뀌고, 바뀌는 건 그 **뒤**뿐(위 무효화 표).
bk.invalidAfter = math.min(bk.invalidAfter, i)
local function gatedRecompute()
bk.invalidAfter = math.min(bk.invalidAfter, i) -- 나중 emit도 같은 무효화가 필요
if not blocker:IsOn() then
recompute(ownerKey, bk)
end
-- ⭐ [2026-08-25, 7라운드 `H-102`] `i`**캡처하지 않는다** — splice가
-- 자리를 당기면 박힌 인덱스가 낡는다. `bk`가 소유한 역방향 맵에서
-- 현재 인덱스를 조회한다(`slot._elemIndex`와 같은 개념을 Dispatch로
-- 격상한 것 — 위 `recompute` 절).
--
-- ⚠️ [2026-08-25 정정, `/code-review high`] 키는 **`len`이 아니라 이
-- 자리의 토큰**이다. `len`은 자리마다 유일하지 않다 — 같은
-- `Source`/`State`가 두 자리의 길이를 몰면 역방향 맵에서 **두
-- 자리가 한 항목으로 접혀** 엉뚱한 인덱스를 무효화하고, 앞선
-- 자리는 영영 다시 offset을 못 받는다. `token`은 등록 시점에
-- 만드는 유일한 테이블로 `bk.tokens[i]`에 같이 저장되고 splice가
-- 다른 배열과 **함께** 당긴다. (`slot._elemIndex`가 물리 요소를
-- 키로 쓰는 건 요소가 자리마다 유일해서 성립하는 것 — 그 성질을
-- Dispatch에선 토큰이 맡는다.)
local cur = bk.indexOfToken[token]
bk.invalidAfter = math.min(bk.invalidAfter, cur) -- 나중 emit도 같은 무효화가 필요
if blocker:IsOn() then return end -- 배치 등록 중
if bk.recomputeBlocker:IsOn() then return end -- ⭐ recompute 재진입 중
recompute(ownerKey, bk)
end
if isState(len) then

View file

@ -36,7 +36,10 @@ Effect는 내부적으로 각 dep에 구독을 거는 걸로 구현 — State/So
`state:Observer(...)`, `Ref``:Callback`.
**⚠️ [표기 정정, 2026-08-24 6라운드 `H-14`] 여기 원래 *"`fn`은 포지셔널 인자로
`state`를 받고(`fn(state)`)"*라고 적혀 있었는데 그건 폐기된 단수 시절
표기다** — 확정 시그니처는 **`fn(self: EffectHandle) -> (() -> ())?`**이고
표기다** — 확정 시그니처는 **`fn(self: EffectHandle) -> ...(() -> ())`**이고
(**[2026-08-25 `H-95`]** 가변 반환 팩 — 옛 `-> (() -> ())?`는 콜백이 "선언보다
적게 반환"할 때 strict에서 막혀 `Effect(function() print("x") end, s)` 같은
정상 용례가 안 통과했다)
**deps는 `fn`에 안 넘어간다**(아래 "`Effect(fn, ...deps)`" 절이 소스,
dep 값은 사용자가 클로저로 직접 읽는다).
같이 붙어 있던 *"이 `fn(state)`가 lazy `State` 핸들을 받는다는 전제는 확정
@ -130,37 +133,138 @@ gcconn/gchold 복사가 전부다 — **`Destroying`도, cleanup 저장도, 그
cleanup을 필요로 하므로 필드 쪽이 자연스럽고, `Destroying` 클로저와
`Rerun`이 같은 자리를 읽게 된다.
**의사코드 — `bindLifetime`이 부르는 두 훅**(**[2026-08-24 신설]** 감사 3라운드가
*"호출부만 있고 정의가 없다"*고 지적해 보강. 다른 신설 헬퍼는 전부 바디가
있는데 이 둘만 산문뿐이었다):
**⭐⭐ [2026-08-25 재설계, 7라운드 `H-58`/`H-59`/`H-64`/`H-70`] dep 등록은
`bindLifetime`이 아니라 생성자에서 한 번만 한다.** 옛 의사코드는 `Ref` 콜백을
`_bindDestroying`에서 (재)등록했는데, `ref-plan.md`가 확정한 *"등록 즉시 그
값으로 1회 호출됨"*과 겹쳐 **바인드마다 `Rerun`이 `Ref` dep 수만큼 돌았다**.
같은 문제가 State dep에도 있다 — `source-state-plan.md`가 확정한
*"`fn`은 등록 시점에 즉시 1회 실행된다"* 때문이다.
### 확정 구조 — 강한 주인은 항상 `Effect`, 발화 게이트는 `canExecute` 하나
```
Effect ──강──▶ _deps = { [Ref | State] = fn | Observer } ← 강한 주인은 언제나 Effect
Ref.Callbacks ──약──▶ fn (`ref:WeakCallback(fn)`)
Observer 전역 레지스트리 ──약──▶ Observer (`observer:WeakSubscribe()`)
발화 게이트: 전부 `canExecute(handle)` 하나로
```
**사용자 확정(2026-08-25)**: *"그냥 간단하게 저 강한 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*`
대신한다**.
- **왜 `WeakRef`가 아니라 `WeakCallback`인가**(사용자 지적): *"Ref 안에
항상 콜백이 쌓인다는것도 문제가 됨."* 클로저가 핸들을 약하게 잡는
`WeakRef`는 "`Ref`가 `Effect`를 붙든다"만 풀고 "`Ref.Callbacks`에 죽은
클로저가 쌓인다"는 못 푼다. `WeakCallback`은 둘 다 푼다 —
`Effect ↔ cb` 순환이 자기완결이라 Luau GC가 통째로 수거하고 `Ref`
항목도 같이 사라진다. **`WeakRef`는 만들지 않는다.**
- 이건 `blocker-plan.md`의 onunblock 핸들 보관과 **같은 패턴**이다 —
강한 주인은 소비자 쪽, 등록처는 weak.
### 의사코드 — 생성자 / `bindLifetime`이 부르는 두 훅 / `Rerun`
```lua
-- quad-base, Effect.luau
function Effect(fn, ...)
local self = setmetatable({ fn = fn, _deps = {}, _epochs = EpochMap() }, EffectHandle)
self._blocker = Blocker()
-- (0) deps 검증 — 생성자에서 한 번만 도는 검사라 hot path가 아니다.
-- `select("#", ...)`로 순회해야 `nil` 구멍이 조용히 배열을 자르지 않는다.
local seen = {}
for i = 1, select("#", ...) do
local d = select(i, ...)
if d == nil then error("Effect: dep #" .. i .. " is nil", 2) end
if not (isState(d) or isSource(d) or isRef(d)) then
error("Effect: dep #" .. i .. " is not a State/Source/Ref", 2)
end
if not seen[d] then seen[d] = true end -- 중복 dep은 조용히 무시(error 아님)
end
-- (1) dep 등록 — **여기서 한 번만**. 즉시-1회 호출은 Blocker로 억제한다.
-- ⭐ 클로저는 **하나**로 통일한다 — 아래 "공통 상류를 공유해도 한
-- 파동에 fn은 한 번만" 절의 그 클로저다. `from`을 받아
-- `_epochs:Update(from)`가 참일 때만 `Rerun`해야 다이아몬드 dedup이
-- 산다(안 하면 `A → b`, `A → c`, `Effect(fn, b, c)`에서 `A:Set()`
-- 한 번에 `fn`이 두 번 돈다 — 2026-08-21에 닫은 그 버그).
-- 시그니처가 `(self, from)`인 것은 `state:Observer(fn)`의 확정
-- 계약이다(`base/source-state-plan.md`).
self._blocker:On()
local function onDepFire(_, from)
if not canExecute(self) then return end -- 발화 게이트
if self._blocker:IsOn() then return end -- 등록 구간 억제(Update보다 먼저)
if self._epochs:Update(from) then
self:Rerun()
end
end
for d in seen do
if isRef(d) then
self._deps[d] = onDepFire -- ⭐ 강한 주인 = Effect
d:WeakCallback(onDepFire) -- Ref 쪽은 약함
else
local o = d:Observer(onDepFire)
self._deps[d] = o -- ⭐ 강한 주인 = Effect
o:WeakSubscribe() -- 전역 레지스트리는 약함
end
-- ⭐ dep이 `Epoch`인지로 갈린다 — `state-epoch-plan.md` §4의 시딩 규칙
-- 그대로다. **`Source`/`Ref`는 `Epoch`지만 `State`는 아니다**(§2·§8) —
-- 무조건 `Sync`하면 State dep이 `.Revision` 없는 키로 들어가
-- `Refresh()`가 영영 변화를 못 보고 포탈 캐치업이 죽는다.
if isEpoch(d) then
self._epochs:Sync(d)
else
self._epochs:TrackFrom(d.valueEpochMap)
end
end
self._blocker:OffWithoutEmit()
-- (2) 설치 — 생성 즉시 1회. **바인드로 미룰 수 없다**(아래 캐비엇).
self:Rerun()
return self
end
```
- **`_installing` 플래그는 폐기됐다** — 그건 생성자 구간만 덮어 바인드
구간을 놓쳤다. 억제는 사적 `Blocker` 하나가 전담한다(**사용자 지적**:
*"해당 맥락의 도구인 Blocker 가 존재함 … 이미 Slot 에서 사용중임. 모든
옵저버와 callback 등록에 있어서 이를 수행해야할 것임."*).
- **⚠️ 생성 즉시 1회 실행은 바인드로 미룰 수 없다.** **사용자 판단**:
*"Effect 가 바운딩 될 때 실행되는건 문제가 있습니다. 그 이팩트 실행
결과를 바로 받아서 처리하는 아래쪽 요소가 있으면, 순차 처리가 전혀 안
되거든요. 초기 값이 못 쓰게 되는거죠."* 그 따름정리로 **바운딩 없이
버려지는 `Effect`는 UB**다 — cleanup이 안 불린다. `Observer`와 달리
`Effect`는 "죽기 전에 처리해주겠다"가 계약이라 성격이 다르다.
```lua
-- quad-base, Effect.luau — `base/lifecycle-pattern.md`의 bindLifetime에서만 불린다.
-- 비공개(`_` 접두사): 사용자 표면이 아니라 배관이다.
function EffectHandle:_bindDestroying(inst)
-- 재바인드(포탈 재마운트)면 옛 연결부터 끊는다 — 멱등.
self:_unbindDestroying()
self:_unbindDestroying() -- 재바인드(포탈 재마운트)면 옛 연결부터 — 멱등
-- (1) leaf가 죽는 순간 cleanup을 정확히 1회. `LP-2`가 확정한 유일한 훅 지점.
self._destroyConn = onDestroying(inst, function()
self:_unbindDestroying() -- 자기 연결/콜백을 먼저 정리(재진입 방지)
local cleanup = self._cleanup
self._cleanup = nil -- 두 번 안 불리게
if cleanup then cleanup() end
self:_unbindDestroying()
self:_consumeCleanup()
end)
-- (2) `Ref` dep 콜백 (재)등록 — State/Source dep의 `_observers` cascade와 대칭.
-- `unbind`가 뗐던 걸 여기서 다시 건다(그래서 포탈이 성립한다).
for _, ref in ipairs(self._refDeps) do
local cb = function(value)
-- ⭐ 발화 게이트 — State dep이 전파 루프에서 `canExecute(observer)`
-- 걸러지는 것과 같은 자리(아래 `H-7` 절). 해제가 늦거나 누락되는
-- 창을 이게 덮는다.
if not canExecute(self) then return end
self:Rerun()
end
self._refCallbacks[ref] = cb -- 해제 때 정확히 이 클로저를 떼려면 보관해야 함
ref:Callback(cb)
-- (2) 캐치업 — **조건부 최대 1회**. dep 등록은 이미 생성자에서 끝났다.
-- 설치돼 있지 않으면(파괴로 소진됐으면) 재설치, dep이 변했으면 재실행.
-- ⚠️ `Refresh()`**먼저 부른다** — 그건 비교만 하는 게 아니라 자기
-- 키를 라이브로 다시 읽어 **갱신**한다. `or` 단축평가에 걸면
-- `not self._installed`가 참일 때(재설치 경로) 건너뛰어, 재설치 뒤에도
-- `_epochs`가 파괴 전 리비전을 들고 있어 **다음 emit이 헛되이 한 번
-- 더 돈다**(`Rerun`은 `_epochs`를 안 건드린다).
local depsChanged = self._epochs:Refresh()
if not self._installed or depsChanged then
self:Rerun()
end
end
@ -169,28 +273,116 @@ function EffectHandle:_unbindDestroying()
self._destroyConn:Disconnect()
self._destroyConn = nil
end
for ref, cb in pairs(self._refCallbacks) do
ref:Uncallback(cb) -- `base/ref-plan.md`의 신설 표면(`H-7`)
self._refCallbacks[ref] = nil
end
-- **`_cleanup`은 안 부른다** — 위 2번 계약. 여기서 부르면 `destroySlotTree`
-- `_detachCleanup`을 손으로 비운 뒤 unbind하는 경로에서 이중 호출이 된다.
-- **`Ref` 콜백도 Observer도 안 뗀다** — `Weak*`로 걸려 있고 발화는
-- `canExecute`가 막는다. **`_cleanup`도 안 부른다**(아래 2번 계약).
end
-- cleanup 소진: 읽고 → 지우고 → 실행. 이 순서라야 이중 호출이 없고,
-- `_cleanup`의 유무가 곧 "설치돼 있는가"가 된다.
function EffectHandle:_consumeCleanup()
local c = self._cleanup
self._cleanup = nil
self._installed = false -- ⭐ 아래 캐비엇 참고 — cleanup 유무로는 판정 못 한다
if c then c() end
end
```
**⚠️ [2026-08-25 `/code-review high` 정정] "설치돼 있는가"를 `_cleanup`
유무로 판정하면 안 된다 — 별도 `_installed` 플래그가 필요하다.** 여기 한때
`if self._cleanup == nil or ...`라고 적어뒀는데, **`fn`의 cleanup 반환은
선택**이라(`Effect(function() print("x") end, s)`처럼 아무것도 안 돌려주는
게 흔한 정상 용례) `_cleanup`이 **항상 `nil`**인 Effect가 존재한다. 그러면
바인드/포탈 재마운트마다 조건이 참이 되어 `fn`이 다시 돌고 — 이 재설계가
없애려던 `H-58`(바인드마다 `Rerun`)이 **그대로 되살아난다.**
`_installed``Rerun`이 끝날 때 참, `_consumeCleanup`에서 거짓이 된다.
**⭐ [2026-08-25 신설, 7라운드 `H-60`] `EffectHandle:Rerun()` 정의.**
지금까지 호출부만 다섯 곳이고 정의가 없었다.
```lua
function EffectHandle:Rerun() -- 공개 메소드, 무인자
if self._running then
self._pending = true -- 실행 중 재진입 → 지연
return
end
self._running = true
repeat
self._pending = false
self:_consumeCleanup()
self._cleanup = self.fn(self)
self._installed = true -- cleanup 반환 여부와 무관하게 "설치됨"
until not self._pending -- 재요청이 또 오면 또 돈다
self._running = false
end
```
- **재진입은 지연 재실행**이다. **사용자 판단**: *"Effect 의 실행 안에서
뭔가 수행되어 rerun 해야할 상황이 발생하면, 지연해 두었다 나중에 재실행
하는건 어떤지(실행이 끝나고 나서). 실제로 Effect 안에서 state 등을 바꾸는
상황은 react 등지에서 흔함."*
- **`canExecute` 확인은 호출부가 한다** — `Ref` 콜백·전파 루프가 이미
그렇게 한다. 사용자가 `fn` 안에서 직접 부르는 경로는 게이트하지 않는다.
- **error 시 UB** — 전파되고 복구하지 않는다(`_running`이 참으로 남는 것
포함). *"에러가 난 이후 데이터의 무결이 깨져도 별 책임 안 진다는 quad의
일반 동작"*(사용자). 수렴 책임은 사용자 `fn`에 있고 무한 루프도 UB다.
**⭐ [2026-08-25 신설, 7라운드 `H-65`] 재바인드는 재설치, 재사용은 팩토리
패턴.** 파괴로 cleanup이 소진된 `Effect`를 다시 바인드하면 위 (2)의
`not self._installed`가 참이라 **재설치**된다. 죽음을 표시하는 별도 부기는
만들지 않는다 — **사용자 지적**: *"파괴 클린업은 결국 inst.Destroying 에
이벤트 바인딩인데 이 바인딩도 파괴 이후 자동 삭제된다 … gchold 나 gcconn 도
알아서 잘 풀린 상태라, 그냥 가만히 두면 삭제 이후 다시 사용에 있어 다시
실행해줘야한다는 것 이외엔 아무 문제가 없어요."*
같은 `fn`**여러 인스턴스로** 쓰고 싶으면 `Effect`를 넘기지 말고
**팩토리를 넘긴다**:
```lua
local function TimerEffectFactory(data: { timerSource: Source<number> }): Effect
return Effect(function(self)
...
return function() ... end
end, data.timerSource) -- 주입받은 것을 그대로 deps로도 쓸 수 있다
end
```
- **사용자 결론**: *"차라리 Effect 를 만들어내는 팩토리를 넘기는 패턴을
권장해야할듯 해요 … Clone 도, Userdata 도, 템플릿도 필요하지 않다."*
초기 1회 실행 문제가 자연히 해결되고, 템플릿이 실행되어 찌꺼기로 남는 걸
막으려 따로 뭘 할 필요도 없다. 자식의 계약은 `({...}) -> Effect` 하나이고
부모가 더 큰 타입을 넘겨도 **부분 성립**으로 해결된다. 무엇보다 **주입받은
`Source`/`Ref`를 그대로 deps로 넣을 수 있다** — userdata로는 안 되던 것이다
(*"이건 ud 가 deps 에 대해서는 아무 처리가 못 했던것과 비교해 더 간단하면서도,
기능적임"*). modifier에서 이미 권해온 패턴이기도 하다.
- 부수로 *"이팩트를 여러곳에 바인딩하면?"*도 자연히 해결된다(매번 새 인스턴스).
- **검토 후 안 만들기로 한 것**: `Effect:Clone()`, `Effect<UD>:Userdata()` /
`SetUserdata`·`GetUserdata`, `Effect.Template`, `WeakRef`.
- **`onDestroying(inst, fn)`은 백엔드 주입 op이다** — base는 `Instance`
모른다(`base/slot-plan.md`의 `native*` 절과 같은 이유). quad-roblox 구현은
`inst.Destroying:Connect(fn)` 한 줄. **[2026-08-24 반영 완료]** 주입 op
전체 목록의 단일 소스는 `base/architecture.md``EngineOps.luau` 줄이고
거기에 등재했다(`ROADMAP.md` M5 배너에도 같이 적었다) — 한때 여기
*"`ROADMAP.md` M5에 추가할 것"*이라고만 적어 **소스를 잘못 지목**했었다.
- **필드 셋이 새로 생긴다**: `_destroyConn`(연결 핸들), `_refDeps`(생성자가
`...deps``isRef`인 것만 모아둔 배열), `_refCallbacks`(`ref → 내가 건
클로저`). 앞의 둘은 `_observers`와 같은 층이고, 마지막 것은 **해제 시 정확히
자기 클로저만 떼기 위해** 필요하다(`Ref.Callbacks`가 셋이라 값으로 떼야 한다).
`inst.Destroying:Connect(fn)` 한 줄. 주입 op 전체 목록의 단일 소스는
`base/architecture.md``EngineOps.luau` 줄이다.
- **필드 목록**: `_destroyConn`(연결 핸들), **`_deps`**(`Ref|State` → 내가 건
`fn|Observer`, **강참조**), `_epochs`(`EpochMap` — `Ref``Epoch`라 균일),
`_blocker`(등록 구간 억제), `_cleanup`, **`_installed`**(설치 여부 —
cleanup 반환이 선택이라 `_cleanup`으로는 판정 못 한다),
`_running`/`_pending`(재진입).
**옛 `_refDeps`/`_refCallbacks`/`_observers`/`_installing`은 `_deps` 하나와
`_blocker`로 대체됐다.**
### ⭐ `Ref` 의존성의 해제 경로 (2026-08-24 확정, 6라운드 손 트레이싱 `H-7`)
> **⭐⭐ [2026-08-25 갱신, 7라운드 `H-58`/`H-59`] 해제 경로 대신 `Weak*`
> 등록으로 바뀌었다.** 아래가 진단한 누수(*"leaf가 죽어도 `ref.Callbacks`
> 클로저가 영원히 남는다"*)는 그대로 유효하지만, **해법이 바뀌었다**
> `unbindLifetime`/`:Unsubscribe()`에서 `:Uncallback`하는 대신
> `ref:WeakCallback(cb)`로 걸고 **강한 주인을 `Effect._deps`에 둔다**.
> 그러면 `Effect`가 죽을 때 콜백도 같이 죽어 항목이 자연히 사라지고,
> 바인드/언바인드마다 떼었다 붙이는 춤이 없어진다(그 춤이 `H-58`
> 중복 `Rerun`을 만들던 원인이다). 위 "확정 구조" 절이 소스.
> **아래 `canExecute` 게이팅은 그대로 유효하다** — *"해제는 누수를,
> 게이팅은 발화를 막는다"* 중 **뒤쪽 절반**이 여전히 이 절의 결론이다.
`Effect(fn, someRef)`의 leaf가 죽어도 **`ref.Callbacks`에 클로저가 영원히
남았다** — `Ref`엔 콜백 해제 API가 없었고(`:Uncallback` 류 없음)
`canExecute` 게이팅도 안 걸린다(그건 Observer 쪽 배관이다). 그 클로저가
@ -236,7 +428,17 @@ named 자리 바인드 같은 실제 기능이 확정되면 평범한 우선순
**보강 — `EffectHandle`의 내부 Observer 바인딩 세부(2026-08-09 열한 번째
세션, 재확인 후 명시화)**:
**⚠️ [2026-08-24 6라운드 손 트레이싱 `H-8`] 이 문단 전체가 아직 "Observer 하나"
> **⛔⛔ [2026-08-25 폐기, 7라운드 `H-58`/`H-59`] 이 문단 전체는 옛 모델이다.**
> 위 "확정 구조 — 강한 주인은 항상 `Effect`" 절이 **정반대로** 확정했다 —
> **`bindLifetime`/`unbindLifetime`은 `Effect` 핸들 하나에만 적용되고**
> 내부 Observer로 cascade하지 않는다. dep 등록은 **생성자에서 한 번만**
> `WeakSubscribe`/`WeakCallback`으로 하고, 발화 여부는 `canExecute(handle)`
> 전담한다. 아래가 서술하는 `_observers` 배열/cascade/`Subscribe` 순회는
> **전부 `_deps` 하나와 `_blocker`로 대체됐다**(위 "필드 목록").
> 아래는 히스토리로만 읽을 것 — **이 문단대로 짜면 `H-58`의 중복 `Rerun`
> 되살아난다.**
**⚠️ [2026-08-24 6라운드 손 트레이싱 `H-8`, 2026-08-25 폐기] 이 문단 전체가 아직 "Observer 하나"
전제로 쓰여 있었다 — `_observer`(단수)를 `_observers`(배열)로 읽을 것.**
아래 절이 확정한 `Effect(fn, ...deps)`(N-deps)와 정면으로 어긋났고, 그대로
구현하면 **2번째 이후 dep의 Observer엔 `canExecute` 판정 근거가 아예 안 실려**
@ -295,9 +497,20 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로
`self` 반환(Observer와 동일한 fluent 대칭).
- **`:Subscribe()`** — Observer가 쓰는 것과 같은 강참조 레지스트리에
자신(또는 `state` 있는 경우 내부 Observer)을 등록 — 새 메커니즘 아님,
기존 레지스트리 재사용. 이후 로컬 변수로 참조를 안 들고 있어도 계속
살아있음(Observer와 동일 관용구).
**핸들 자신**을 등록 — 새 메커니즘 아님, 기존 레지스트리 재사용. 이후
로컬 변수로 참조를 안 들고 있어도 계속 살아있음(Observer와 동일 관용구).
- **⭐ [2026-08-25 확정, 7라운드 `H-59`] "핸들 자신이냐 내부 Observer냐"는
더 이상 구현 세부가 아니다 — 핸들 자신이다.** 옛 서술은
*"자신(또는 `state` 있는 경우 내부 Observer)"*, *"`handle._observers`만으로
충분한지는 구현 세부"*였는데, `H-7`/`H-11`이 **핸들 자신의 생존 판정**에
의존하는 배선을 추가하면서 그 선택이 계약이 됐다. 내부 Observer만
등록하면 (a) `handle.Subscribed`가 안 세워져 `canExecute(handle)`
영원히 거짓이고, (b) deps 없는 `Effect(fn):Subscribe()`는 등록할 게
아예 없어 핸들이 GC되고 cleanup이 유실된다.
- **`:Subscribe()`가 하는 일은 그것 하나뿐이다** — 내부 Observer와 `Ref`
콜백은 **생성자에서 이미 `Weak*`로 걸려 있다**(위 "확정 구조" 절).
`Subscribed = true`가 서는 순간 `canExecute(handle)`이 참이 되어 그
경로들이 살아난다.
- **⚠️ 용도는 완전히 top-level(모듈/스크립트 레벨, 어떤 Instance
생명주기에도 안 묶인) 사이드 이펙트로 한정할 것 — 특정 `inst`
묶인 경우엔 leaf 부착(`bindLifetime`)을 쓰지 `:Subscribe()`를 쓰지
@ -348,12 +561,14 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로
*"relate 로 effect 핸들러 쪽에서 old 값을 직접 들고 있어야 하고 dedup
이면 retract 에서 old 를 안 지워주고 process 로 조회해보고 같으면
dedup 되어야하는듯."*
- **⭐ 단, 내부 Observer cascade도 그 분기 *안*에 있어야 한다**
(5라운드 `EF-5`, 확인됨) — `EffectHandle`은 자기 자신뿐 아니라
`handle._observers`까지 같이 bind/unbind해야 하는데, 그 cascade가 dedup
분기 **밖**에 있으면 handle과 내부 Observer의 바인딩 상태가 갈린다
(handle은 그대로인데 Observer만 풀리는 식). 구현 시 이 한 줄을 반드시
같은 `if` 안에 둘 것.
- **⛔ [2026-08-25 폐기, 7라운드 `H-58`/`H-59`] `EF-5`의 "내부 Observer
cascade도 그 분기 안에" 요구는 사라졌다.** 원문은 *"`EffectHandle`은
자기 자신뿐 아니라 `handle._observers`까지 같이 bind/unbind해야 하는데,
그 cascade가 dedup 분기 밖에 있으면 handle과 내부 Observer의 바인딩
상태가 갈린다"*였는데, **`bindLifetime`/`unbindLifetime`이 이제 핸들
하나에만 적용되고 cascade 자체가 없다**(위 "확정 구조" 절,
`_observers` 필드도 `_deps`로 통합돼 폐기). 갈릴 상태가 없으므로
이 요구는 성립하지 않는다 — **그대로 구현하면 `nil`을 순회한다.**
- **[2026-08-21 기준]** 남은 건 **구현 시 회귀 확인**뿐이고, 설계상 열린
항목이 아니다.
- **`:Subscribe()`한 핸들에서는 `:Unsubscribe()`가 Observer의 것을 그냥
@ -363,8 +578,13 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로
계약은 "생애주기가 끝나는 시점에 마지막 cleanup이 정확히 1회 호출된다"
이고 leaf 사망은 그 "끝"의 신호 중 하나일 뿐이라, `:Unsubscribe()`
동일하게 "지금 끝났다"는 신호로 취급해야 계약이 일관됨:
1. `state`가 있으면 내부 Observer도 `:Unsubscribe()`해서 향후 재실행을
끊고,
1. **⚠️ [2026-08-25 정정, 7라운드 `H-58`/`H-59`]** 여기 원래 *"`state`가
있으면 내부 Observer도 `:Unsubscribe()`해서 향후 재실행을 끊고"*라
적혀 있었는데, 내부 Observer는 생성자에서 `:WeakSubscribe()`로 걸리고
**해제하지 않는다** — 발화는 `canExecute(handle)`이 막는다(위
"확정 구조" 절). `:Unsubscribe()``handle.Subscribed = false`
만들면 그 게이트가 곧바로 거짓이 되므로 **향후 재실행은 그것만으로
끊긴다.** 단수 `state` 전제도 이미 `...deps`로 대체됐다.
2. **직전(또는 유일한) cleanup을 정확히 1회 호출** — leaf가 죽을 때
하던 것과 정확히 같은 이벤트를 수동으로 앞당기는 것.
3. **idempotent, 그리고 이후 leaf가 실제로 죽어도 cleanup이 중복
@ -405,8 +625,28 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect
- **`Effect(fn, ...deps)`가 의존성을 여러 개 받고, 각각에 맞는 구독을 건다** —
State/Source면 `Observer`, `Ref``:Callback`. `:With`로 합치지 않는다.
**[2026-08-25 정정]** 각각 `:WeakSubscribe()` / `:WeakCallback()`으로
걸고, 강한 주인은 `Effect._deps`다(위 "확정 구조" 절).
- **⭐ [2026-08-25 확정, 7라운드 `H-70`] deps 검증** — 지금까지
비어 있던 세 자리를 정한다.
- **`nil` dep은 error.** `{...}` + `ipairs`로 순회하면 `nil` 구멍 뒤가
조용히 잘리므로 **`select("#", ...)`로 돌아야** 한다.
- **State/Source/`Ref`가 아닌 값은 error.** `H-40``:List`의 요소
검증을 블랙리스트에서 화이트리스트로 뒤집은 것과 같은 성격이다 —
이물 dep은 전파할 것이 없으므로 조용히 무시하면 "왜 안 발화하지"만
남는다.
- **중복 dep은 조용히 무시**(error 아님). **사용자 근거**: *":With 이나
시소한 연산으로 다른 State 가 된다던가 하면 deps 가 겹쳐도, 근원
source 가 겹쳐도 에러를 안 냄. Ref 도 유사한 부분."* `Ref``Epoch`
승격돼(`base/ref-plan.md`) `EpochMap`이 키로 dedup하므로 **공짜로**
처리된다 — 옛 `_refCallbacks[ref] = cb` 덮어쓰기로 먼저 건 클로저가
`Ref.Callbacks`에 남던 버그도 같이 사라진다.
- 검증은 전부 **생성자에서 한 번만** 도므로 hot path가 아니다.
error `level`**2**(사용자 입력 검증) — `base/architecture.md`
error 계약 절.
- **⭐ [확정, 2026-08-24 6라운드 손 트레이싱 `H-14`] `fn`의 시그니처는
`fn(self: EffectHandle) -> (() -> ())?` 이고, `...deps`는 의존성 선언일 뿐
`fn(self: EffectHandle) -> ...(() -> ())` 이고(**[2026-08-25 `H-95`]** 가변
반환 팩으로 정정 — 옛 `-> (() -> ())?`는 정상 용례를 막았다), `...deps`는 의존성 선언일 뿐
`fn`에 넘어가지 않는다.**
**사용자 확정**: *"`Effect( fn(self: Effect)->()->(), ...deps )` 가 맞는듯.
Observer 처럼 바로 상위 state 가 있는게 아니라 Effect 를 주는게 맞아보이고,
@ -431,14 +671,20 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect
- **`Ref` 의존성의 발화 시점은 `Set`될 때뿐**이다(Ref는 반복 재설정이
가능하므로 그때마다). 채워지지 않은 상태는 발화가 아니다.
- **최초 1회를 한 번만 돌리는 장치**: 의존성마다 구독을 걸면 각 구독의 "등록
즉시 1회 실행"이 N번 발화하므로, 설치 구간 동안 발화를 눌러뒀다가 마지막에
한 번만 실행한다. **[2026-08-21 확정] 이건 `Effect` 내부 플래그로 한다 —
게이트도 `Blocker`도 안 쓴다.** 한때 *"`Blocker`의 "`state:Block()` 없이
직접 쓰는" 용례를 그대로 재사용"*이라 적고 정확한 모양을 `Gate` 설계에
걸어뒀는데, `Gate`가 **빈 배치일 땐 통지를 안 하는 것**으로 확정되면서
성립하지 않는 게 확인됐다(`base/gate-plan.md`의 8번) — 설치 구간엔 어떤
`Set`도 안 일어나 게이트에 쌓이는 소스가 없으므로 게이트가 내보낼 것 자체가
없다. 설치 중 발화를 누르는 플래그 하나면 되고 새 메커니즘이 필요 없다.
즉시 1회 실행"이 N번 발화하므로, 등록 구간 동안 발화를 눌러뒀다가 마지막에
한 번만 실행한다.
**⭐⭐ [2026-08-25 정정, 7라운드 `H-58`] 그 억제는 `Effect` 내부 플래그
(`_installing`)가 아니라 사적 `Blocker` 하나가 한다.** 여기 한때
*"[2026-08-21 확정] 이건 `Effect` 내부 플래그로 한다 — 게이트도 `Blocker`
안 쓴다"*고 적혀 있었는데, 그 플래그는 **생성자 구간만 덮어 바인드 구간을
놓쳤다**(`Ref` 콜백을 바인드마다 재등록하던 옛 모델에서 `Rerun`이 dep 수만큼
돌았다). 지금은 dep 등록이 **생성자 한 곳**으로 모였고 억제는
`self._blocker:On()``:OffWithoutEmit()` 구간이 맡는다 —
`materializeSlotTree`가 쓰는 관용구와 같은 모양이고, 위 "확정 구조" 절과
생성자 의사코드가 소스다. **`_installing`은 폐기된 필드다.**
(2026-08-21에 `Gate` 재사용을 접었던 근거 — *"설치 구간엔 어떤 `Set`도 안
일어나 게이트에 쌓이는 소스가 없다"*, `base/gate-plan.md`의 8번 — 는 그대로
유효하다. 게이트가 아니라 `Blocker`를 쓰는 이유이기도 하다.)
- **⭐ [2026-08-21 해소] 의존성들이 공통 상류를 공유해도 한 파동에 `fn`은 한 번만
돈다 — `Effect`가 자기 `EpochMap`을 하나 든다.** 갭은 실재했다: `A → b`,
`A → c`, `Effect(fn, b, c)`에서 `A:Set()` 한 번에 `b`가 자기 observer를,
@ -453,21 +699,29 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect
```lua
-- 각 dep의 내부 Observer가 공통으로 거는 클로저
function(self, from)
if handle._installing then return end -- 설치 구간 억제 (아래)
if not canExecute(handle) then return end -- 발화 게이트
if handle._blocker:IsOn() then return end -- 등록 구간 억제 (위 정정)
if handle._epochs:Update(from) then
handle:Rerun() -- 직전 cleanup 호출 후 fn 재실행
end
end
```
**⚠️ 억제 플래그가 `Update`보다 먼저여야 한다** — 등록 시점의 즉시 1회
**⚠️ 억제 확인이 `Update`보다 먼저여야 한다** — 등록 시점의 즉시 1회
실행에는 `from`이 없어서(`nil`, `base/source-state-plan.md`
"`state:Observer(fn)`" 절) `Update(nil)`이 들어가게 된다. 순서를 뒤집으면
설치 발화가 맵을 건드려 **그 파동의 첫 진짜 emit이 접힐** 수 있다
(2026-08-21 커밋 전 `/code-review high` 발견).
- **`Ref` 의존성은 이 맵에 안 낀다** — `Ref``Epoch`가 아니고
`:Callback`으로 발화하므로 `from`이 없다. `Ref` 쪽 발화는 그대로 매번
`fn`을 돌린다(`Ref`는 반복 재설정마다 도는 게 계약이고, 공통 상류 문제
자체가 없다).
(2026-08-21 커밋 전 `/code-review high` 발견). **[2026-08-25]** 플래그가
`_blocker:IsOn()`으로 바뀌었을 뿐 순서 제약은 그대로다.
- **⭐ [2026-08-25 정정, 7라운드 `H-58`] `Ref` 의존성도 이 맵에 낀다** —
여기 한때 *"`Ref`는 `Epoch`가 아니고 `:Callback`으로 발화하므로 `from`
없다"*고 적혀 있었는데, **`Ref``Epoch`로 승격**되며(`base/ref-plan.md`)
공개 `.Revision`을 갖게 됐다. 그래서 `_epochs`가 State/Source/`Ref`를
**같은 방식으로** 담고(**⚠️ [2026-08-25 정정]** 한때 여기 "State/Source/
`Ref`를 균일하게"라 적었는데 **`State``Epoch`가 아니다** — 등록이
`isEpoch`로 갈려 `Source`/`Ref`는 `:Sync`, `State``:TrackFrom`이다,
`base/state-epoch-plan.md` §4·§8), 같은 `Ref`를 두 번 dep으로 넣어도 키 dedup으로 접힌다
(`H-70`). `Ref`가 반복 재설정마다 도는 계약 자체는 안 바뀐다 — 리비전이
매번 갱신되므로 `Update`가 매번 `true`다.
- **검토했다 접은 대안**: deps를 하나의 파생 노드로 수렴시켜 다이아몬드
dedup에 태우기 — 노드가 늘고 "N deps → N observers" 구조를 바꿔야 해서
위 안보다 못하다. `useEffect`처럼 "N번 돌아도 무방"으로 계약을 느슨하게
@ -475,14 +729,20 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect
- 근거 기록은 `reference/epoch-brand-composition.md`(이 갭이 `EpochMap`
분리의 직접 발단이었다).
- **leaf dedup/cascade가 전부를 덮어야 한다** — 의존성이 N개면 내부 Observer도
N개라, `EffectHandle`의 bind/unbind cascade와 dedup 분기가 **그 전부**를
같이 처리해야 한다(위 `E-10`/`EF-5`와 같은 함정). 사용자 확인: *"어차피
모든 옵져버들이 내부에 들어가 있을것이므로 가능하다."*
N개다(위 `E-10`/`EF-5`와 같은 함정). 사용자 확인: *"어차피 모든 옵져버들이
내부에 들어가 있을것이므로 가능하다."*
**⭐ [2026-08-25 정정, 7라운드 `H-58`/`H-59`] 다만 그 "전부"를 덮는 주체는
bind/unbind cascade가 아니다** — `bindLifetime`/`unbindLifetime`은 이제
**핸들 하나에만** 적용되고, N개 dep의 발화 여부는 `canExecute(handle)`
하나가 전담한다(위 "확정 구조" 절). 등록도 생성자 한 곳에서 끝난다.
**우선순위**: 새 코어 메커니즘이 아니라 `Effect` 표면 확장이므로 M2의
`Effect` 구현과 같이 간다. **[2026-08-21]** 여기 있던 "억제 장치 때문에
`Gate`보다 뒤"라는 순서 제약은 **없어졌다** — 억제가 `Effect` 내부 플래그로
확정돼 `Gate`에 안 걸린다.
`Gate`보다 뒤"라는 순서 제약은 **없어졌다** — 억제가 `Gate`에 안 걸린다.
**[2026-08-25 정정]** 억제 수단이 내부 플래그에서 **사적 `Blocker`**로
바뀌었으므로 선행은 `Blocker`의 기본 메커니즘(`On`/`Off`/`IsOn`/
`OffWithoutEmit`)이다 — 그건 `GateNode`/`:Policy`와 무관하게 독립 완결이라
`Gate`보다 뒤일 필요는 여전히 없다(`ROADMAP.md` M2의 그 각주).
## 해결됨 — Effect/Observer 관계 (2026-08-07 여섯 번째 세션, 이전 미해결 절 대체)

View file

@ -91,10 +91,43 @@ end)
**확정 형태**:
```lua
-- setup: (emit: () -> ()) -> (onUpstreamEmit: () -> ())
-- setup: (emit: (commit: boolean?) -> boolean) -> (onUpstreamEmit: () -> ())
local gated = state:Gate(setup) -- ComputeNode처럼 GateNode를 하나 만든다
```
**⭐⭐ [2026-08-25 확정, 7라운드 `H-55`/`H-86`] `emit`에 인자와 반환값이
붙는다.** 인자 목록이 늘지 않으므로 `H-49`의 *"`setup` 시그니처는 안
바뀐다"*를 최소로만 되짚는다.
| 호출 | 뜻 |
|---|---|
| `emit()` / `emit(true)` | 평소대로 흡수 집합을 flush하고 전파 |
| `emit(false)` | **흡수 집합을 버리고** 전파하지 않는다 |
| 반환값 | "실제로 내보내거나 버릴 게 있었는가"(= 집합이 비어 있지 않았는가) |
- **왜 필요한가 (1) — 버릴 수가 없었다(`H-55`).** 아래 4번은 *"emit 없이
푸는 경로는 집합을 **버려야** 한다"*고 확정하는데, `Blocker`
`blocker:Policy(emit)` **값**으로 떨어져 나온 뒤로 정책이 손에 쥔 건
`emit` 하나뿐이라 **집합에 닿는 통로가 없다.** 그래서
`Trailing = false`/`Cancel`/`OffWithoutEmit`이 "버린다"가 아니라
"미룬다"가 되어, 버리기로 했던 옛 원천들이 **다음 버스트에 실려
나간다**(4번이 경고한 바로 그 모양이 정책 분리로 되살아난 것).
- **왜 필요한가 (2) — 읽을 수가 없었다(`H-86`).** 5번이 *"`pending` 같은
정책 상태는 `HasBlockedEmit`으로 흡수한다"*고 확정했는데, `blocker-plan.md`
`HasBlockedEmit`을 **게이트 노드의 `withheld`**로 흡수해버려 **양쪽 다
안 들고 있다.** 그 결과 `Throttle`의 창이 idle로 못 돌아가
**leading이 첫 버스트 이후 영구 소실**되고 타이머 체인이 안 끝나
`base/debounce-throttle-plan.md`의 "8. 라이프사이클 / GC 분석" 절이 확정한 "유계 GC"가 깨진다(실측 확인).
- **`Throttle``onWindowEnd`가 이 한 줄로 닫힌다**:
```lua
if not emit() then window = nil -- 보류분 없었음 → 완전 idle 복귀
else rearm() end
```
- **기각된 안**: `setup(emit, discard, hasWithheld)`처럼 핸들을 늘리는 것
(인자 목록 자체가 바뀌어 `H-49`를 더 크게 되짚는다), 정책이 자기
`pending`을 다시 드는 것(`H-32`를 손으로 다시 막아야 하고 버리기는
여전히 안 닫힌다).
`Blocker`는 **그 위에 얹히는 별개 프리미티브**로, `state:Block(blocker)`
내부에서 이 배선을 그대로 쓴다. 탑레벨 `Gate(...)` 생성자는 **안 만든다.**
`:Gate`가 메소드라고 `:Apply`와 배타적인 것도 아니다 — 사용자 지적대로
@ -120,8 +153,13 @@ end)
- **`__call`은 안 쓴다.** 사용자도 *"이상적이여 보이지는 않음"*이라 했고,
타입 쪽 근거가 하나 더 있다 — `__call` 테이블이 Luau에서 `(State<T>) -> U`
함수 타입 자리에 그대로 들어가는지가 불확실하다(들어가지 않는 쪽이 유력).
`Apply`를 쓸 이유 자체가 없어졌으므로 확인할 필요도 없어졌지만, 혹시
되살아나면 `luau-test` 스파이크 한 개로 판정할 것.
**⭐ [2026-08-25 실측 확정, 7라운드 `H-94`] 안 들어간다.**
`luau-analyze`로 재현했고(제네릭·비제네릭 양쪽), 타입 레벨 `__call`
`self`도 못 받는다. 여기서 *"확인할 필요도 없어졌다"*고 접었지만
**`Debounce`/`Throttle` 쪽엔 그 불확실성이 그대로 남아 있었다** — 바로
아래 항목이 *"`Debounce`/`Throttle`은 `:Apply` 그대로 둔다"*를 확정하기
때문. 그래서 애플리커티브 팩토리는 `__call`이 아니라 **지정된 필드**로
자기를 노출한다(`base/source-state-plan.md`의 "`state:Apply(factory)`" 절).
- **2단 구조는 그대로 유효하다** — 사용자 관찰(*"Gate 의 callback 으로 얻어진
emit과, 리턴해낸 클로저가 호출되는걸로 배선은 가능"*) 대로, 바깥 함수가
**그 노드의 `emit`을 캡처**하고 반환 클로저가 상류 emit마다 정책을 태운다.
@ -173,11 +211,23 @@ end)
중첩 flush의 `clear`가 돌아 **바깥 전파의 남은 갈래가 빈 집합**을 받는다.
**모델이 아니라 그 의사코드가 문제였다**`emit()`이 flush인 이상 들어가는
순간 떼어내는 게 원래 모양이고, 그러면 그 경로 자체가 없다:
```lua
-- ⭐ [2026-08-25 보강, 7라운드 `H-89`] 아래 8번과 `H-89`가 확정한
-- **네 단계**를 그대로 적는다. 한때 이 스니펫이 가운데 둘만 갖고
-- 있어서, 이걸 보고 짜면 "빈 배치도 통지" + "`emitEpochMap`을 언제
-- `Sync`하는지 불명" 두 실수가 난다.
function GateNode:_flush(commit: boolean?)
if next(self._withheld) == nil then return false end -- (1) 빈 배치 얼리리턴
local batch = self._withheld
self._withheld = newWeakK() -- (2) 새 테이블. clear가 아니다
if commit == false then return true end -- 버리기(H-55) — 전파도 Sync도 안 함
self.emitEpochMap:Sync(batch) -- (3) 전파 **앞**에서 한꺼번에
emitDownstream(self, batch) -- (4) 떼어낸 batch를 페이로드로
return true
end
```
local batch = self._withheld
self._withheld = newWithheld() -- 새 테이블. clear가 아니다
emitDownstream(self, batch) -- 떼어낸 batch를 페이로드로 넘긴다
```
반환값이 `emit(commit) -> boolean`의 그 반환값이다 — **"실제로
내보내거나 버릴 게 있었는가"**(위 2번, `H-55`/`H-86`).
**⚠️ [정정, 2026-08-24 6라운드 손 트레이싱 `H-9`] 그 새 테이블도 weak여야
한다.** 여기 원래 `self._withheld = {}`라고 적혀 있었는데, 그러면 위에서
**weak key로 확정한 집합이 첫 flush 스왑에서 평범한 테이블로 바뀐다**
@ -212,7 +262,12 @@ end)
게이트가 몇 겹으로 겹쳐도 각 층이 자기 집합을 들고 있으므로 어느 층이
먼저 풀리든 정보가 안 샌다.
- **게이트의 `emitEpochMap`은 수신 때가 아니라 실제로 전파할 때** 갱신한다
(집합 전체에 대해 한꺼번에, `:Sync(batch)`). **이건 `state-epoch-plan.md`
(집합 전체에 대해 한꺼번에, `:Sync(batch)`).
**⭐ [2026-08-25 명시, 7라운드 `H-89`] `:Sync(batch)`는 전파 *앞*이다** —
flush 순서는 **빈 배치 얼리리턴 → 스왑 → `:Sync(batch)` → 전파**다.
지금까지 *"실제로 전파할 때"*라고만 적혀 앞/뒤가 안 정해져 있었다.
그리고 **수신 시점의 판정에는 `:Peek`을 쓴다**`:Update`는 덮으므로
이 예외와 양립하지 않는다(`base/state-epoch-plan.md` §3). **이건 `state-epoch-plan.md`
§4 의사코드의 유일한 예외이고, 그 문서에 예외로 기록돼 있다.** 그래야 "내가 하류로 던진
리비전"이라는 맵의 뜻이 게이트에서도 참이 된다 — 유보 중 같은 리비전이
다른 경로로 또 오면 규칙 2로 걸려 정책을 한 번 더 태우는데, 이미 집합에
@ -224,14 +279,85 @@ end)
게이팅이 매 프레임 `On()` → … → `OffWithoutEmit()`을 도는 동안 집합이
**단조 증가**하고, 나중에 아무 `Epoch`나 한 번 통과하는 순간 **버리기로
했던 옛 원천들이 같이 실려 나가** 하류가 폐기된 통지로 무효화된다.
- **⭐⭐ [2026-08-25 정정, 7라운드 `H-67`] 여기 근거로 든 용례가
틀렸다.** `Dispatch.drive`의 배치 게이팅은 `base/blocker-plan.md`
*"이 용례는 `state:Block()`을 전혀 호출하지 않으므로 gated state도 …
생기지 않는다"*고 명시한 경로라 **애초에 `withheld` 집합이 없다.**
결론(비워야 한다)은 그대로 유효하고 **근거가 될 용례만 바꾼다**
`state:Block(b)`로 만든 gated state에 `b:OffWithoutEmit()`을 반복해
거는 경우, 또는 `Throttle{Trailing = false}`가 창마다 버리는 경우가
실제로 집합이 단조 증가하는 자리다.
- **⭐⭐ [2026-08-25] 정책은 이걸 스스로 할 수 없다** — 위 2번의
`emit(false)`가 그 통로다(`H-55`).
- 그렇게 비우고 나면 하류의 `emitEpochMap`은 뒤에 남지만, 그 `Epoch`
다음 진짜 emit이 규칙 1/2로 걸려 **스스로 낫는다** — 별도 조치 불필요.
**⭐ 그래서 `setup` 시그니처는 안 바뀐다.** 집합을 채우는 건 정책이 아니라
**노드**이고, 노드는 정책이 뭘 하는지 들여다볼 필요조차 없다(위 단순화).
**⭐ 그래서 `setup`의 인자 목록은 안 바뀐다.** 집합을 **채우는** 건 정책이
아니라 **노드**이고, 노드는 정책이 뭘 하는지 들여다볼 필요조차 없다(위
단순화). **[2026-08-25 정정]** 다만 집합을 **버리고/읽는** 것은 정책이
해야 하는 일이었고 통로가 없었다 — 그래서 위 2번의
`emit(commit: boolean?) -> boolean`이 생겼다. 인자 **목록**은 그대로다.
정책은 소스를 몰라도 되고, `Throttle`처럼 나중에 타이머에서 `emit()`
부르는 경우도 그대로 동작한다 — 그때 쌓여 있던 집합이 그대로 나간다.
### ⭐⭐ `GateNode` 조립 — 필드 목록과 `:_receive`/`:_flush` (2026-08-25 신설)
**새 결정이 아니라 조립이다.** 판정 규칙은 `base/state-epoch-plan.md` §4,
흡수 집합은 위 4번, `emit` 시그니처는 위 2번 — 지금까지 **세 문서에 나뉘어
있어 한 곳에서 순서를 볼 수 없었다.** 구현자가 조립을 잘못할 여지를
없애려고 여기 모은다.
```lua
-- 필드 (ComputeNode와 같은 층위)
GateNode = {
_hold = { <상류 State/Source> }, -- 하류 → 상류 강참조(`source-state-plan.md`)
_subs = <weak-키 구독자 집합>, -- 원소는 Observer 값 / 자식 State
valueEpochMap = EpochMap(), -- §4 규칙: 값이 낡았는가
emitEpochMap = EpochMap(), -- ⚠️ 수신 때가 아니라 **flush 때** 갱신
_withheld = newWeakK(), -- 흡수 집합 { [Epoch] = true }
onUpstreamEmit = <정책이 돌려준 클로저>, -- setup(emit)의 반환값
}
function GateNode:_receive(from)
-- (1) 판정은 §4 규칙 1~3을 **그대로** 돈다. 단 emit 쪽은 `Peek`이다 —
-- `Update`는 덮으므로 "유보 중엔 아직 안 던졌다"는 맵의 뜻이 깨진다.
local valueChanged = self.valueEpochMap:Update(from)
local emitChanged = self.emitEpochMap:Peek(from)
if valueChanged then self:_invalidate() end -- 캐시 카운터 갱신
if not (valueChanged or emitChanged) then return end -- 규칙 3: 삼킨다
-- (정책도 안 돌고 집합에도 안 넣는다)
-- (2) 통과한 것만 흡수 집합에 합친다. `from`이 집합이면 unfold해서 합친다
-- — 게이트-게이트 중첩에서 중복이 저절로 접힌다(집합이라서).
if isEpochSet(from) then
for epoch in from do self._withheld[epoch] = true end
else
self._withheld[from] = true
end
-- (3) 정책에 넘긴다. 정책은 손에 `emit` 하나만 쥐고 있고,
-- 그걸로 flush(`emit()`)도 버리기(`emit(false)`)도 조회(반환값)도 한다.
self.onUpstreamEmit()
end
function GateNode:_flush(commit: boolean?): boolean -- 이게 정책이 받는 `emit`
if next(self._withheld) == nil then return false end -- (1) 빈 배치면 아무것도 안 함
local batch = self._withheld
self._withheld = newWeakK() -- (2) 새 테이블. clear가 아니다
if commit == false then return true end -- 버리기 — 전파도 Sync도 안 함
self.emitEpochMap:Sync(batch) -- (3) 전파 **앞**에서 한꺼번에
self:_emitDown(batch) -- (4) 떼어낸 batch가 페이로드
return true
end
```
- **반환값이 곧 위 2번의 `emit(commit) -> boolean`**이다 — "실제로 내보내거나
버릴 게 있었는가"(`H-55`/`H-86`).
- **`valueEpochMap`도 있다는 걸 여기서 명시한다** — 지금까지 *"규칙 1~3을
그대로 돈다"*는 문장에서 추론해야 했다.
- **`_emitDown`은 State와 같은 것**이다(`base/source-state-plan.md`의
"전파 루프 — 확정 의사코드" 절) — 게이트가 전파 루프를 따로 갖지 않는다.
5. **생명주기.** 게이트 노드가 잡는 자원(타이머/플래그)이 언제 죽는가 —
지금 설계대로면 다운스트림이 다 죽으면 GC(팩토리는 weak 추적,
`debounce-throttle-plan.md` 5-4).

View file

@ -335,15 +335,22 @@ function bindLifetime(inst, value)
-- `slot._detachCleanup`(Detach 요소를 파괴하는 유일 경로)과 `OnDestroyed`
-- 통째로 무동작이었다(`base/effect-plan.md`).
-- **왜 이 자리인가**(사용자 판단 2026-08-24): `Destroying`은 엔진이 아는
-- 요소라 엔진을 다루는 자리에 있어야 하고, 이 함수는 이미 `Effect`의 내부
-- Observer 목록으로 cascade하며 값을 들여다본다. `Effect`가 바인드되는
-- 요소라 엔진을 다루는 자리에 있어야 하고, `Effect`가 바인드되는
-- 경로가 둘(children 배열 leaf / `activateList``_detachCleanup` 직접
-- 바인드)이라 **호출부 쪽에 두면 반드시 한쪽이 샌다.**
--
-- ⭐⭐ [2026-08-25 정정, 7라운드 `H-58`/`H-59`] **내부 Observer로 cascade하지
-- 않는다.** 여기 원래 `for _, observer in ipairs(value._observers) do
-- bindLifetime(inst, observer) end`가 있었는데, 그 필드 자체가 폐기됐고
-- (`_deps` 하나로 통합) dep 등록은 **생성자에서 한 번만** 일어난다
-- (`WeakSubscribe`/`WeakCallback`). 그대로 두면 `_observers``nil`이라
-- 순회에서 죽고, 피해 가도 **바인드마다 `Rerun`이 도는 `H-58`
-- 되살아난다.** 발화 게이팅은 전부 `canExecute(handle)` 하나가 맡는다 —
-- `base/effect-plan.md`의 "확정 구조" 절이 소스.
if isEffect(value) then
for _, observer in ipairs(value._observers) do -- N-deps cascade
bindLifetime(inst, observer)
end
value:_bindDestroying(inst) -- Destroying 연결 + Ref dep 콜백 (재)등록
value:_bindDestroying(inst) -- Destroying 연결 + **조건부 캐치업 1회**
-- (`if not self._installed or
-- self._epochs:Refresh() then self:Rerun() end`)
-- 의사코드는 `base/effect-plan.md`가 소스.
-- 그 안에서 주입 op `onDestroying(inst, fn)`
-- 부른다(base는 Instance를 모른다).
@ -352,15 +359,16 @@ end
function unbindLifetime(value)
-- [2026-08-24 `H-11`] bind의 대칭 — **cleanup은 부르지 않는다.**
-- `Destroying` 연결을 끊고 `Ref` dep 콜백을 떼기만 한다. cleanup을 여기서
-- 부르면 `destroySlotTree``_detachCleanup`을 손으로 비운 뒤 unbind하는
-- 경로에서 이중 호출이 된다(`base/effect-plan.md`의 그 항목이 소스).
-- 이 대칭 덕에 포탈이 자연히 성립한다 — 언마운트가 떼고 재마운트가 다시 건다.
-- cleanup을 여기서 부르면 `destroySlotTree``_detachCleanup`을 손으로
-- 비운 뒤 unbind하는 경로에서 이중 호출이 된다(`base/effect-plan.md`).
--
-- ⭐⭐ [2026-08-25 정정, 7라운드 `H-58`] **`Ref` 콜백도 내부 Observer도
-- 안 뗀다.** 둘 다 생성자에서 `Weak*`로 걸려 있고 발화는 `canExecute`
-- 막는다 — 떼었다 붙이는 그 춤이 `H-58`의 원인이었다. 포탈은 이제
-- "떼고 다시 걸기"가 아니라 **재바인드 시 조건부 캐치업 1회**로
-- 성립한다(`_bindDestroying`).
if isEffect(value) then
value:_unbindDestroying()
for _, observer in ipairs(value._observers) do
unbindLifetime(observer)
end
value:_unbindDestroying() -- Destroying 연결만 끊는다
end
local gchold = BindData:GetWeak(value, "gchold")
@ -512,12 +520,26 @@ Destroy됐거나 `unbindLifetime`된 `value`는 `canBound`가 **참**이라
`canExecute`가 "어디서 불리는가"는 지금까지 어느 문서에도 코드로 없었음(위
정정 배너 참고). 확정된 위치는 **State의 전파 루프**:
- State는 자기 구독자(Observer의 emit 클로저)를 **weak로** 담는다 — 살려두는
책임은 State가 아니라 `gchold`(leaf) 또는 전역 `Subscribed` 테이블(전역)에
있고, 어디에도 안 묶인 Observer는 그냥 GC되어 구독 목록에서 자연히 빠짐.
- 발화 시 각 구독자에 대해 `canExecute(observer)`를 확인하고, 거짓이면
**그 구독자만 조용히 건너뜀**(no-op) — 죽은 `inst`를 건드리는 시도가
일어나지 않게 막는 위 "해야 할 일은 딱 하나" 원칙의 실제 구현 지점.
- State는 자기 구독자를 **weak-키로** 담는다 — 살려두는 책임은 State가
아니라 `gchold`(leaf) 또는 전역 `Subscribed` 테이블(전역)에 있고, 어디에도
안 묶인 Observer는 그냥 GC되어 구독 목록에서 자연히 빠짐.
**⭐ [2026-08-25 정정, 7라운드 `H-56`] 집합의 원소는 "Observer의 emit
클로저"가 아니라 **Observer 값**이다.** `bindLifetime(inst, observer)`
Observer **값**을 키로 `BindData`에 gcconn을 복사하므로, 집합에 클로저를
담으면 identity가 달라 `canExecute`가 **항상 거짓**이 된다.
- 발화 시 **Observer/Effect 구독자에 대해서만** `canExecute(observer)`
확인하고, 거짓이면 **그 구독자만 조용히 건너뜀**(no-op) — 죽은 `inst`
건드리는 시도가 일어나지 않게 막는 위 "해야 할 일은 딱 하나" 원칙의
실제 구현 지점.
**⭐⭐ [2026-08-25 정정, 7라운드 `H-56`] 자식 State 노드는 이 게이트를
안 탄다.** 여기 한때 *"각 구독자에 대해"*라고만 적혀 있었는데, 그대로
짜면 `:With`/`:Compute`/`:Gate`가 만든 자식 노드가 **전부 걸러진다**
(자식은 `bindLifetime`된 적도 `:Subscribe()`된 적도 없어
`isBoundAlive`가 항상 거짓) — `A:Set()`이 파생 State에 한 번도 안 닿아
그 아래 모든 Observer가 침묵한다. **확정 의사코드는
`base/source-state-plan.md`의 "전파 루프 — 확정 의사코드" 절이 소스**이고,
자식 노드의 생존은 `canExecute`가 아니라 같은 문서의 `_hold` 불변식이
책임진다.
**`state:Observer(fn)`의 "등록 즉시 1회 실행"은 이 게이팅과 무관**하다 —
그건 Observer 생성자 자체의 계약이라 `bindLifetime` 이전에 동기적으로

View file

@ -425,7 +425,7 @@ Modifier에는 없음).
**[정정, 2026-08-18 구현 전 QA]** 여기 짝으로 적혀 있던 두 서술이 이번
라운드에 바뀌었다 — (a) "동적인 건 **문자열 폴백**"의 그 폴백
(`store "key"` 문자열 커링)은 **기각**됐고 동적 키는
`store:GetDynamic<<T>>(name)`으로 감, (b) 정적 필드가 "**자주 쓰는 것만**"이
`store:Of<<T>>(name)`으로 감(**[2026-08-25]** 옛 이름 `GetDynamic`), (b) 정적 필드가 "**자주 쓰는 것만**"이
아니라 **생성기가 "GUI에 쓰이는 모든 인스턴스"를 전량 찍어냄**
(`base/bind-system-plan.md`). Modifier 타입 생성(M7)이 재사용하는 건 그
**패턴**(제네릭 + 생성된 정적 필드)이지 옛 범위 서술이 아님.

View file

@ -188,6 +188,11 @@ export type function CheckVersion(actual: type, pattern: type): type
**2개 이상**이면 단일 꺾쇠(`Foo<A, B>`)가 비교 연산자로 오파싱되니
반드시 이중 꺾쇠(`Foo<<A, B>>`)를 써야 한다(코퍼스에 이미 있던
`AttributeKey<<T>>` 관례와 같은 이유).
**⭐ [2026-08-25 확장, 7라운드 `H-73`] 이 관례는 타입 자리 전용이
아니다** — Luau의 generic type instantiation은 **값 호출부**에서도,
**콜론 메소드**에서도 동작해 `T`를 실제로 묶는다
(`store:Of<<number>>("x")`). `luau-analyze` 음성 대조군까지
확인했다 — 상세는 `base/store-plan.md`의 "타입 추론 문제" 절.
`Version` 필드는 Luau 내장 `index<T, "Version">` type function으로 뽑는다
(수동 `t:readproperty(...)`보다 간결 — **사용자 제안**으로 채택, 실측 확인

View file

@ -167,11 +167,41 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디
`Ref(default):Callback(fn)`이 코퍼스 전반에 쓰인다) — 해제 핸들은
**`:Uncallback(fn)`**으로 뗀다. 셋이라 `Callbacks[fn] = nil` 한 줄이고,
중복 등록이 dedup되므로(위) "몇 번 떼야 하나"가 없다.
- `EffectHandle`은 자기가 건 콜백을 들고 있다가 `unbindLifetime`
`:Unsubscribe()`에서 `:Uncallback`한다 — State/Source dep의
`_observers`와 대칭(`base/effect-plan.md`).
- **⛔ [2026-08-25 폐기, 7라운드 `H-58`] 여기 원래 *"`EffectHandle`은
자기가 건 콜백을 들고 있다가 `unbindLifetime``:Unsubscribe()`에서
`:Uncallback`한다"*고 적혀 있었다.** 아래 `:WeakCallback` 항목이 그걸
**대체한다**`Effect`는 생성자에서 `ref:WeakCallback(cb)`로 한 번만
걸고 강한 주인을 `_deps`에 두며, 바인드/언바인드는 `Ref`를 아예 안
건드린다(떼었다 붙이는 그 춤이 `H-58`의 "바인드마다 `Rerun`"을 만들던
원인이다). `:Uncallback`**사용자가 직접 건 콜백을 떼는** 공개
표면으로 남는다. `base/effect-plan.md`의 "확정 구조" 절이 소스.
- **`:Wait()`의 대기자는 이 표면의 대상이 아니다** — 발화 시 소진되므로
뗄 일이 없다.
- **⭐⭐ [2026-08-25 신설, 7라운드 `H-58`/`H-59`] `:WeakCallback(fn)`
약하게 등록하는 짝.** `Weak` 쪽이 **프리미티브**이고 평범한
`:Callback(fn)`은 그 위에 "GC 안 되도록 킵" 하나를 더 얹은 것이다.
**사용자 확정**: *"동작 자체는 Weak 아닌것과 동일하게 가고, 가드도
동일하나 단순히 gc 안 되도록 킵 해주는 부분만 제거된 함수가 됩니다.
따라서 내부적으론 Weak 를 구현해 두고, Weak 아닌 곳에서 Weak 를
수행하고 gc 처리만 두면 돼요."*
- **자료구조**: 약한 등록은 **weak-키 테이블**(`__mode = "k"`)에
들어간다 — `.Callbacks`(강함)와 별도 테이블이다. Lua의 weak 모드는
테이블 단위라 항목별로 섞을 수 없다. 발화 순회는 두 테이블을 다
훑고(각각 스냅샷), `:Uncallback(fn)`은 양쪽을 다 본다.
- **왜 필요한가**: `Effect`가 자기 dep `Ref`에 콜백을 걸면
`.Callbacks`가 강한 셋이라 **`Ref`가 그 `Effect`를 영원히 붙든다**.
그렇다고 바인드/언바인드마다 떼었다 붙이면 *"등록 즉시 1회 호출"*에
걸려 **바인드마다 `Rerun`이 돈다**(`H-58`). `:WeakCallback`으로 걸고
**강한 주인을 `Effect._deps`에 두면** 둘 다 사라진다 —
`Effect ↔ cb` 순환이 자기완결이라 Luau GC가 통째로 수거하고
`Ref` 쪽 항목도 같이 없어진다(`base/effect-plan.md`의
"확정 구조 — 강한 주인은 항상 `Effect`" 절).
- **`WeakRef`(1-슬롯 약참조 박스)는 만들지 않는다** — 그건 "`Ref`가
`Effect`를 붙든다"만 풀고 "죽은 클로저가 `.Callbacks`에 쌓인다"는
못 푼다(사용자 지적). 아이디어만 기록:
`WeakRef:Set(v)`/`:Get()`만 주고(`.Value`가 아닌 이유는 내부 값이
항상 있다고 확정된 상태가 아니라서), 내부는
`setmetatable({}, {__mode = "v"})`의 1-슬롯.
- **`:Wait(thread?)``thread` 인자(2026-08-07 여섯 번째 세션, 사용자
제안, 확정)**: 생략(`nil`)하면 `coroutine.running()`으로 호출 중인
코루틴 자신을 캡처해 대기자로 등록하고 그 자리에서 `coroutine.yield()`
@ -325,6 +355,41 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디
담기는 용도/leaf에 바인딩하는 용도 둘 다에 여전히 맞아 더 나은 대안이
없다는 결론, 용어 정리 대상에서 제외됨.
### ⭐⭐ `Ref``Epoch`를 만족한다 — `.Revision` 신설 (2026-08-25 확정, 7라운드 `H-58`/`H-64`/`H-70`)
**사용자 제안**: *"혹은 Ref 까지도 Epoch 를 구현해줘도 좋다는 생각.
'바뀌였나?' 보는건 source 에 대한 계약이라, 똑같이 실제 값을 가지는 Ref 도
이를 구현해주는데 문제가 없음."*
`base/state-epoch-plan.md` §2가 **이미 예상해둔 확장**이다 — 그 절이
`Epoch``Source`가 아니라 최소 인터페이스로 일반화한 실익으로 든 게
*"`Source`가 아닌 원천(외부 시계 등)이 특수 분기 없이 낀다 … 그건
`EpochBrand:register(self)` 한 줄로 끝난다"*였다.
- **`Ref<T>`에 공개 필드 `Revision: number`가 생긴다.** `:Set()`
`Source`와 **같은 한 줄**로 갱신한다 — `self.Revision = bit32.bnot(-self.Revision)`
(그 문서 §2의 랩어라운드 감소). 공개여야 구조적 만족이 타입 레벨에서
성립한다.
- **`EpochBrand:register(self)`** — `Source``SourceBrand`이면서 동시에
`EpochBrand`인 것과 같은 다중 태깅(`base/brand-plan.md`).
- **`.Callbacks`(푸시 경로)는 그대로다** — `Epoch`는 **부기**일 뿐,
전파 메커니즘을 대체하지 않는다.
- **무엇이 닫히나**:
- `Effect._epochs`(`EpochMap`)가 `Ref` dep을 **`Source`와 같은 방식으로**
담는다(**⚠️ [2026-08-25 정정]** 한때 "State/Source/`Ref`를 균일하게"라
적었는데 **`State``Epoch`가 아니다** — 등록이 `isEpoch`로 갈려
`Source`/`Ref`는 `:Sync`, `State``:TrackFrom`, `base/state-epoch-plan.md`
§4·§8. 균일해지는 건 **판정 쪽**이다).
그래서 포탈 재마운트의 캐치업이 dep 종류에 따라 갈리던 것(`H-64`)이
**대칭**이 되고, 판정이 `if self._epochs:Refresh() then self:Rerun() end`
한 줄이 된다.
- 같은 `Ref`를 deps에 두 번 넣어도 `EpochMap`이 키로 dedup하므로
**공짜로** 처리된다(`H-70`) — 옛 `_refCallbacks[ref] = cb` 덮어쓰기로
먼저 건 클로저가 `.Callbacks`에 남던 버그도 같이 사라진다.
- `Ref` dep 전용 스냅샷 배열 같은 별도 부기가 필요 없어진다.
- **`Ref`는 여전히 `State`가 아니다** — emit(전파)이 없고 `Get`/`Compute`도
없다. `Epoch`를 만족한다는 건 "직전과 구별되는 표식을 들고 있다"뿐이다.
### `Ref`의 retract — `State<Ref>` 재바인드 시 이전 Ref에 `nil` (2026-08-12 여덟 번째 세션, `TagHandler`와 같은 메커니즘 재사용)
> **✅ [2026-08-13 열네 번째 세션] 하강 diff 재디스패치 반영 완료.**
@ -380,13 +445,18 @@ function RefLeafHandler.process(inst, k, v, index)
Dispatch.setOffsetSource(inst, k, None)
Dispatch.setLength(inst, k, 0, inst)
local old = relate:GetStrong(inst, k)
local old = relate:GetWeak(inst, k) -- ⭐ [2026-08-25 `H-71`] 쓰기가 SetWeak이므로 읽기도 Weak
-- (안 맞추면 old가 항상 nil이라 dedup이 통째로 죽는다)
if old ~= v then -- 이미 같은 Ref가 이 자리를 차지 중이면 재통지 skip
bindLifetime(inst, v) -- v가 이미 다른 자리에 살아있으면 여기서 즉시 error —
-- 이중 배치 방지("이중 배치 방지" 절 참고), 별도 Relate 불필요
v:Set(inst)
end
relate:SetStrong(inst, k, v)
-- ⭐ [2026-08-25, 7라운드 `H-71`] `SetStrong` 아님 — `v:Set(inst)`로 값이
-- 자기 바깥 키를 되참조하므로 `SetStrong`이면 **100% 샌다**(실측 50/50).
-- dedup은 순수 성능 최적화라 weak로 낮춰도 최대 손해가 "한 번 놓침"이고,
-- `v`는 gchold가 이미 강하게 잡는다(`base/relate-plan.md`의 슬롯 표).
relate:SetWeak(inst, k, v)
return function(nextValue)
-- nextValue는 nil이거나 같은 핸들러가 곧 처리할 새 Ref(타입 보장됨) — v는
-- 이 process 호출이 만든 클로저가 직접 캡처(Relate 재조회 불필요)
@ -399,7 +469,7 @@ function RefLeafHandler.process(inst, k, v, index)
-- 지워져, 곧바로 이어지는 process가 `old ~= v`를 항상 참으로 보고
-- `v:Set(inst)`를 재실행함(콜백 헛 재통지). 즉 아래 dedup 항목이
-- 약속한 "spurious면 둘 다 스킵"이 성립을 안 했음.
if relate:GetStrong(inst, k) == v then relate:SetStrong(inst, k, nil) end
if relate:GetWeak(inst, k) == v then relate:SetWeak(inst, k, nil) end
end
end
end
@ -411,7 +481,7 @@ end
불리게 함.
- **children 배열 리터럴 `Ref`도 같은 코드 경로를 그대로 씀** — 그 경우
이전 클로저가 (StoreBind 경로가 아니라 이 리터럴 구성 자체가 처음이므로)
아예 없고 `relate:GetStrong(inst,k)`도 `nil`이라 `process`가 바로
아예 없고 `relate:GetWeak(inst,k)`도 `nil`이라 `process`가 바로
`v:Set(inst)`로 끝남. "1회성 리터럴 구성"과 "반복 재바인드"가 하나의
구현으로 자연히 커버됨, 케이스 분기 불필요.
- **타입: 비-nilable `T`도 정당한 용도(사용자 확인, 2026-08-12 여덟 번째

View file

@ -90,7 +90,66 @@ Destroy 전까지 userdata 동일성을 고정해주므로, 모든 `inst`-키 `R
`slot._mountedInst`는 그 값(`slot`)이 자기 키(`physicalTarget`)를 다시
참조하는 필드일 뿐이라 GC 안전 — `base/slot-plan.md` "재귀 메커니즘"
절)는 **단일 `Relate` 안에서** 일어나는 한 안전함 — 그 `Relate`의 키(`inst`)가
테이블 *바깥에서* 독립적으로 reachable한지만 판별하면 되기 때문. 하지만
테이블 *바깥에서* 독립적으로 reachable한지만 판별하면 되기 때문.
> **⛔⛔ [2026-08-25 전면 정정, 7라운드 `H-71`/`H-77`] 바로 위 "단일 `Relate`
> 안에서는 안전함"은 **거짓이다.** 커밋된 `quad-base/src/Relate.luau`
> 실측했다.**
>
> 위험은 **값 기준 하나**가 아니라 **슬롯 셋**으로 갈린다:
>
> | 슬롯 | `SetStrong` | `SetWeak` |
> |---|---|---|
> | 바깥 키(`inst`) | weak(설계) | weak(설계) |
> | **내부 키(`key`)** | **강함** | **강함** |
> | 값(`value`) | **강함** | weak |
>
> ```lua
> -- 커밋된 Relate.luau, 그대로
> buckets[inst] = bucket -- buckets는 __mode = "k"
> bucket.StrongMap[key] = value -- 평범한 테이블 → 키·값 모두 강함
> bucket.WeakMap = setmetatable({}, { __mode = "v" }) -- **값만** weak
> ```
>
> Luau엔 ephemeron이 없으므로 **버킷 안의 무엇이든 `inst`를 되참조하면
> `buckets`의 weak 키가 자기 버킷을 통해 살아남는다.**
>
> - **`SetStrong`의 값이 `inst`를 되참조하면 100% 샌다**(`H-71`, 50/50
> 실측). `SetWeak`으로 낮추면 0/50으로 안전해진다.
> - **내부 키가 `inst`를 되참조하면 `SetStrong`/`SetWeak` 둘 다 샌다**
> (`H-77`, 50/50 · 50/50 실측) — `WeakMap`**키는 강하게** 잡기 때문.
> 이 슬롯은 위 규칙 문단에 **아예 등장하지 않았다.**
>
> **그래서 지금 규칙은 이렇다**:
>
> 1. **값이 바깥 키를 되참조하면 `SetWeak`을 쓴다.** dedup 기록처럼
> "다른 곳에서 안전하게 유지되는 것"은 애초에 항상 `SetWeak`이라는
> 아래 규칙과 같은 결이다. 실제로 물리던 자리는
> `RefLeafHandler.process``ObserverEffectLeafHandler.process`
> dedup 기록이고, 둘 다 `SetWeak`으로 정정됐다.
> 2. **내부 키로 쓰는 객체는 바깥 키를 되참조하면 안 된다**`SetWeak`으로
> 도망갈 수 없는 슬롯이라 규칙으로 막는 수밖에 없다. 지금 내부 키가
> 객체인 자리는 둘이다:
> - **`runInitRelate`**(내부 키 = `initFn`) — **안전하다.** `RunInit`
> `initFn(self)`로 module을 **인자로** 받고, 멱등 가드가 성립하려면
> 애초에 **하나의 고정 함수**여야 하므로(인라인 클로저면 매번 새
> identity라 dedup 자체가 무의미) 바깥 변수를 캡처할 이유가 없다.
> - **⚠️ `groupClaimKeys`**(내부 키 = 그룹 `Attribute`의 값 객체 `v`,
> `base/attribute-plan.md`) — **자동으로 안전하지 않다.** 그 값은
> 사용자가 만든 Store/Source이고, 거기 담긴 값이 `inst`로 되돌아갈 수
> 있다(예: 같은 트리의 `Ref`가 그 `inst`로 채워진 뒤 Store에 담기는
> 경우). **[2026-08-25 `/code-review high` 지적]** 여기 한때 *"둘 다
> 안전하다"*고 단정했는데 `runInitRelate` 쪽 논거만 있었다.
> **그래서 이건 증명이 아니라 계약이다** — 그룹 `Attribute`의 값
> 객체는 `inst`를 되참조하면 안 되고, 그 제약을 `attribute-plan.md`
> 같이 적는다. 되참조가 필요해지면 내부 키를 값 객체가 아니라
> **이름/토큰**으로 바꿔야 한다(값 객체는 그때 값 슬롯으로 내려가고,
> 그 슬롯은 `SetWeak`으로 도망갈 수 있다).
> 3. **`luau-test/done/07`에 되참조 음성 대조군을 추가할 것** — 그 파일은
> "GC-native 아키텍처의 핵심 전제를 검증했다"고 여러 문서에 인용되는데
> 실제로는 **안전한 모양만** 봤다.
하지만
**서로 다른 두 `Relate`가 서로의 키를 상대방 값으로 강하게 제공하는
상호 순환**(예: `RelateA[inst]=value`(강)와 `RelateB[value]=inst`(강)가
동시에 존재)은 완전히 다른, 더 위험한 모양 — `inst`의 reachability

View file

@ -991,7 +991,22 @@ fail-fast 톤으로 그 자리에서 막음 — `keyFn` 작성자(주로 위 `it
위 "용어 주의" 참고.
- **`updateFn<UD = any>(item, index: number, offset: Source<number>,
prev: T?, userdata: UD?): (T | nil, UD?)` — 매 reconcile 사이클마다
모든 key에 대해 호출됨.** `:List`는 더 이상 item을 위해 `Source`
모든 key에 대해 호출됨.**
- **⭐⭐ [2026-08-25 정정, 7라운드 `H-95`] 이 반환 타입 그대로면 strict에서
정상 용례가 전부 막힌다.** Luau는 콜백이 **선언된 것보다 적게
반환**하면 에러다(`Expected 'El?, number?', but got 'El?'`) — 이 문서가
드는 예시들이 `return frame` 한 값만 돌려주는데 그게 통과하지 않는다.
**확정 형태는 함수 타입의 유니온**이다:
```lua
type UpdateFn<T, UD> = Fn2<T, UD> | Fn1<T> | Fn0
-- Fn2: (...) -> (T?, UD?) Fn1: (...) -> T? Fn0: (...) -> ()
```
`luau-analyze` 실측에서 네 모양(2개/1개/`nil`/없음)이 전부 통과하고
**엉뚱한 타입을 돌려주면 여전히 잡힌다**(음성 대조군 확인).
*"항상 명시적으로 반환하라"*를 계약으로 두는 안은 기각 — 인체공학이
나쁘고 `--!nocheck` 코드에선 조용히 지나간다. 단일 옵셔널 반환인
`Effect``fn`은 가변 반환 팩(`-> ...(() -> ())`)이 더 맞는다
(`base/effect-plan.md`). `:List`는 더 이상 item을 위해 `Source`
대신 만들어주지 않음(아래 "왜 `Source``:List`가 안 만드는가" 참고,
2026-08-11 세션에 `index`도 같은 원칙으로 편입) — `item`/`index`는
매번 그 사이클의 raw 현재값 그대로 넘어감, 반응형으로 쓸지는
@ -2577,7 +2592,7 @@ function rawUnmount(self, index)
nativeExtract(self._mountedInst, Dispatch.getOffsetAt(self, index), { element })
end
spliceArraysDown(self, index) -- _elements/_elemIndex/lengthList/sourceList/observers/bk.N — 아래 참고
spliceArraysDown(self, index) -- _elements/_elemIndex/lengthList/sourceList/observers/bk.tokens/bk.indexOfToken/bk.N — 아래 참고
recompute(self, bk) -- 자리가 없어지는 경로엔 setLength가 없으므로 여기서 명시 호출
end
@ -2597,7 +2612,8 @@ function rawDetach(self, index)
nativeExtract(self._mountedInst, Dispatch.getOffsetAt(self, index), { element })
end
spliceArraysDown(self, index) -- `_elemIndex`에서도 이 요소가 빠진다(트리 밖이 됨)
spliceArraysDown(self, index) -- `_elemIndex`/`bk.tokens`/`bk.indexOfToken`에서도
-- 이 요소·자리가 빠진다(트리 밖이 됨) — 아래 splice 요구 목록
recompute(self, bk)
end
@ -2810,7 +2826,7 @@ function rawRemove(self, index)
nativeDispose(element) -- 트리 밖이라 offset이 필요 없다
end
spliceArraysDown(self, index) -- _elements/_elemIndex/lengthList/sourceList/observers/bk.N
spliceArraysDown(self, index) -- _elements/_elemIndex/lengthList/sourceList/observers/bk.tokens/bk.indexOfToken/bk.N — 아래 참고
recompute(self, bk) -- outer 자기 자신 레벨에서 딱 1회만
end
```
@ -2836,9 +2852,34 @@ end
그쪽 error 가드엔 안 걸린다). **동기 재진입이라 "체인 도중 yield 금지"
불변식으로는 안 덮인다.** `sourceList``None`으로 채워지는 것과 대칭을
맞춰 창 자체를 없앤다.
- **캐시를 앞으로 당긴다**(`H-3`) — `bk.invalidAfter = math.min(bk.invalidAfter, index)`.
- **캐시를 앞으로 당긴다**(`H-3`) —
`bk.invalidAfter = math.min(bk.invalidAfter, index)`.
`base/dispatch-core-plan.md`의 무효화 표가 규정한 세 규칙 중 하나이고,
지금까지 산문으로만 있고 코드 경로가 없었다.
**[2026-08-25 정정]** 한때 여기 `index - 1`로 적었는데, `recompute`
되감기 재개 지점이 `invalidAfter + 1`에서 **`invalidAfter` 자신**으로
고쳐지며 그 `-1`이 불필요해졌다(그 문서의 `recompute` 절).
- **⭐ 이게 `recompute` 되감기의 신호이기도 하다** — recompute 도중
splice가 나면 그 값이 낮아지고, 진행 중인 루프가 그 지점 다음부터
되감는다(`base/dispatch-core-plan.md`의 `recompute` 절). 그래서
`{a,a,a, b,b, c,c}`에서 앞의 `a,a`가 사라졌는데 커서가 이미 `c`
있어도 복구된다.
- **⭐⭐ [2026-08-25 신설, 7라운드 `H-102`] `bk.tokens``bk.indexOfToken`
같이 밀어야 한다.** `setLength`가 만드는 `gatedRecompute` 클로저는 인덱스를
캡처하지 않고 **자리별 토큰**으로 `bk.indexOfToken[token]`을 조회한다
(`slot._elemIndex`와 같은 개념을 Dispatch 층위로 격상 —
`base/dispatch-core-plan.md`). 그래서 splice가 다음 둘을 반드시 해야 한다:
1. **`bk.tokens`를 다른 배열과 같이 당긴다**(`lengthList`/`sourceList`/
`observers`와 완전히 같은 처리).
2. **밀린 자리 전부에 대해 `bk.indexOfToken[token]`을 새 인덱스로
갱신하고, 사라진 자리의 토큰 항목은 지운다.** 강한 키 테이블이라
안 지우면 **누수**다.
**안 하면 `H-102`가 그대로 남는다** — 토큰은 안 낡지만 그 토큰이 가리키는
인덱스가 낡아, 제거 뒤 `gatedRecompute`가 **엉뚱한 위치로**
`bk.invalidAfter`를 당긴다(앞선 자리가 영영 다시 offset을 못 받는다).
이 목록에 *"옮겨진 클로저의 위치 추적"*이 셋 어디에도 없던 것이 원래
결함이었다.
**[신설, 2026-08-18 구현 전 QA 3라운드] `spliceArraysDown`이 밀어야 하는
배열 목록(아래)에 빠진 게 있었고, `bk.N`도 같이 줄여야 한다는 것 자체가
@ -3298,7 +3339,12 @@ Slot이 마운트될 때 **자기 하위 요소들까지 `bindLifetime`으로
-- `dispose`가 막으려던 바로 그 UB가 일어난다. **가드를 분기 밖으로 올린다.**
function dispose(value)
-- (1) 소유권 가드 — 값 종류와 무관하다(`elementOwner` 조회는 타입을 안 봄)
if elementOwner:GetStrong(value) ~= nil then
-- ⚠️ [2026-08-25 정정, `/code-review high`] `GetStrong(value)`(1-인자)는
-- **항상 `nil`이라 가드가 죽은 코드였다**`elementOwner`는 위에서
-- 3-인자 `SetWeak(element, OWNER, ...)`로만 쓰이고 강한 맵엔 아무것도
-- 안 들어간다(`Relate`는 항상 3-인자 `SetWeak`/2-인자 `GetWeak`, 409행).
-- `H-71`로 dedup 기록까지 `SetWeak`이 되며 강/약 짝맞춤이 더 중요해졌다.
if elementOwner:GetWeak(value, OWNER) ~= nil then
error("dispose: 이 값은 아직 트리가 살아있길 요구 중임 — 먼저 Remove/Extract 할 것")
end
-- (2) [`H-43`] Slot도 Instance도 아닌 값이 백엔드로 그냥 흘러가지 않게

View file

@ -10,7 +10,7 @@
> | 갈라진 곳 | 담는 것 |
> |---|---|
> | **이 문서** | `Source`/`State` 온톨로지·서브타입, 전파 모델, `:With`/`:Compute`/`:Apply`, `previous`, `:Emit`, `Observer`, 구독/생명주기 게이트 |
> | `base/store-plan.md` | Store = 이름 붙은 Source 모음 — `defaults`, eager/lazy 생성, `store.key` 타이핑, `:Set()` 문법 |
> | `base/store-plan.md` | Store = 이름 붙은 Source 모음 — `defaults`, **명시적 초기화**(2026-08-25, 옛 eager/lazy 이중 모델 폐기), `store.key` 타이핑, `:Set()` 문법 |
> | `base/dispatch-core-plan.md` | 디스패치 코어(핸들러 계약, `chains`, 하강 diff) |
> | `base/bind-system-plan.md` | 인스턴스 생성/이벤트 네이밍 인체공학 + 분할 색인 |
@ -89,7 +89,8 @@ RefSource라는 별도 타입은 폐기**하는 쪽으로 수렴.
생기지 않음.
- **`RefSource<T>` 같은 별도 타입은 불필요, `Store({defaults})`
내부적으로 `{[key] = Source(default), ...}`나 다름없게 됨** — Store
쪽 상세(eager/lazy 생성, `defaults` 템플릿의 성격, 구현 스케치)는
쪽 상세(**[2026-08-25]** 명시적 초기화 — 옛 eager/lazy 이중 모델 폐기,
`defaults`의 성격, 구현 스케치)는
`base/store-plan.md`가 소스. 별도 `__values`류 그림자 실값 저장소도
불필요 — Source 객체 자체가 저장소 역할을 함. 이 모델은 이전에
검토했던 "State를 weak table로 캐싱" 절충안보다 더 싸다(래퍼 생성/
@ -112,7 +113,8 @@ RefSource라는 별도 타입은 폐기**하는 쪽으로 수렴.
구현 델리게이션 포함)이라 그 금지와 충돌하지 않음.
- **동적 키 경로도 `State`가 아니라 `Source`를 반환**하는 것으로 자연히
갱신됨 — **[정정, 2026-08-18]** 그 경로는 `store "key"` 문자열 커링이
아니라 `store:GetDynamic<<T>>(name): Source<T>`다(문자열 커링은 기각,
아니라 `store:Of<<T>>(name): Source<T>`다(**[2026-08-25]** 옛 이름
`GetDynamic` — 문자열 커링은 기각,
`base/store-plan.md`의 "타입 추론 문제" 절).
**[해소됨, 2026-08-13 첫 실측 라운드]** 핵심 질문(Source가 State를 구조적으로
@ -205,8 +207,10 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa
전량은 **`base/state-epoch-plan.md`가 소스** — 여기선 이 절의 다른 서술과
어긋나지 않게 요지만 적는다. 여기 있던 "emit은 *항상* 전파된다"는 무조건
서술의 역전 원문은 `archive/always-propagate-no-dedup-superseded.md`.
- **`invalid`(구현 이름 `rawInvalid`) 플래그의 역할은 "내 캐시가 낡았다"는
표시 하나뿐** — 전파를 제어하는 장치가 **아님**. `:Get()`이 호출되면
- **"내 캐시가 낡았다"는 표시의 역할은 그것 하나뿐**(**[2026-08-25 정정]**
한때 *"구현 이름 `rawInvalid`"*라 적었는데 그 필드는 **폐기**됐다 —
지금은 `cacheTargetCount`/`cacheCurrCount` 카운터 쌍이고, 재계산 도중
도착한 무효화와 `fn` 예외를 같이 덮는다. `base/state-epoch-plan.md`) — 전파를 제어하는 장치가 **아님**. `:Get()`이 호출되면
상류로 올라가 재계산하고, 그 결과를 캐시에 넣고, 플래그를 끈다.
- **⚠️ `invalid`로 전파를 접는 것은 지금도 금지다.** 2026-08-14에 그
방식이 폐기된 이유는 `:Get()`을 호출하지 않는 `Observer`(아래
@ -231,6 +235,52 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa
("emit 필요 여부" 열린 질문은 이걸로 해소). 전파가 잦아도 부담이 작은
이유이기도 함 — 신호 하나가 트리를 훑는 비용이지 재계산 비용이 아님.
### ⭐⭐ 전파 루프 — 확정 의사코드 (2026-08-25 신설, 7라운드 `H-56`)
지금까지 이 루프는 **산문으로만** 있었고, 그 공백에서 실제 결함이 나왔다 —
`base/lifecycle-pattern.md`의 *"발화 시 각 구독자에 대해 `canExecute(observer)`
확인하고, 거짓이면 그 구독자만 조용히 건너뜀"*을 글자 그대로 짜면
**자식 State 노드가 전부 걸러진다**(자식은 `bindLifetime`된 적도
`:Subscribe()`된 적도 없어 `canExecute`가 항상 거짓이다). 그러면
`A:Set()``A:With(...)`/`A:Compute(...)` 노드에 **한 번도 안 닿고**
파생 State 아래의 모든 Observer가 침묵한다.
```lua
function State:_emitDown(from)
-- H-23 확정: pairs 순회 중 새 키 추가는 미정의라 먼저 배열로 스냅샷
local snap = {}
for sub in self._subs do snap[#snap + 1] = sub end
for _, sub in ipairs(snap) do
if isState(sub) then -- 자식 노드
sub:_receive(from) -- state-epoch-plan.md §4 규칙 1~3
elseif canExecute(sub) then -- Observer / Effect
sub.fn(sub, from)
end -- 거짓이면 조용히 건너뜀
end
end
```
- **구독자 집합은 하나**(`self._subs`, weak-키)이고 **원소는 Observer
값**이다 — emit 클로저가 아니다. `bindLifetime(inst, observer)`가 Observer
**값**을 키로 `BindData`에 gcconn을 복사하므로, 집합에 클로저를 담으면
`canExecute(클로저)`가 identity가 달라 **항상 거짓**이 된다.
`base/lifecycle-pattern.md` (4)의 *"Observer의 emit 클로저"* 표현은 이
결정에 맞춰 고쳤다.
- **자식 State는 `canExecute`를 안 본다** — 그건 "이 값이 어떤 Instance에
묶여 살아 있는가"를 묻는 판정이고, 자식 노드의 생존은 위
"해소됨 — 중간 State는 `_hold`로 살아남는다" 절의 `_hold` 불변식이
책임진다.
- **두 집합으로 나누는 안은 기각** — emit마다 스냅샷이 두 번이 되고
(아래 비용 참고) 등록/해제 경로도 둘로 갈린다.
- **비용**(7라운드 `H-92`): 이 스냅샷은 **파동이 지나는 노드 수만큼** 배열을
할당한다. `base/state-epoch-plan.md` §2가 테이블 리비전을 기각한 근거가
*"`Set` 한 번마다 테이블 하나를 할당"*이었으므로, 그 문서 §7의 비용
서술은 이 할당을 셈에 넣어 실제와 맞춰야 한다. **정확성 문제는
아니다** — 순회 중 등록이 미정의인 이상 스냅샷은 필요하다.
- **예외 안전성**: 구독자 콜백이 던지면 **그 파동의 나머지 구독자는 그
변경에 대해 영구 침묵**한다(값만 자가치유). `pcall`로 감싸지 않는다 —
`base/architecture.md`의 예외 계약 절이 소스.
### 다이아몬드 의존성은 무엇이 푸는가 (2026-08-14 명확화)
`a → b`, `a → c`, `(b, c) → d` 형태에서 `a`가 한 번 바뀌면 `d`는 두 경로로
@ -300,10 +350,20 @@ State를 여러 갈래가 공유하는 다이아몬드 형태(`b`에서 `c1 = b:
수만큼 중복 실행됨 — `previous` 메커니즘이 막으려던 문제를 반대로 다시
만들어내는 셈이라 방향이 안 맞음.
**"별도 데이터스트럭처 관리" 부담은 실제로는 작음.** "관측해야
실체화된다" 원칙 때문에 살아있는 노드-대-노드 구독 엣지가 필요한 건
실제로 관측되는(`Get()`되는) State뿐 — 중간에 만들어놓고 아무도 안 보는
State는 구독 등록 자체가 안 일어남. 다이아몬드에서 중복 재계산을 막는
**"별도 데이터스트럭처 관리" 부담은 실제로는 작음.**
**⭐⭐ [2026-08-25 정정, 7라운드 `H-62`] 여기 한때 *"실제로 관측되는(`Get()`되는)
State뿐 — 중간에 만들어놓고 아무도 안 보는 State는 구독 등록 자체가 안
일어남"*이라 적혀 있었는데 **틀렸다. 구독 엣지는 생성 즉시(eager) 등록된다.**
**사용자 확정**: *"생성 즉시 밖에 없다. 옵져버가 실행 안 된다면 get 자체가
안 되므로, lazy 하면 아에 등록 될 기회가 없다."* — lazy가 성립하려면 "먼저
`Get()`이 일어난다"가 전제인데 `Get()`을 부르는 주체가 바로 그 등록되지
못한 Observer라 순환이다. `Get()`을 안 하는 Observer가 *"매 변경마다 정확히
한 번 운다"*는 확정과도 양립하지 않았다. 나머지 코퍼스는 이미 eager였다 —
`base/state-epoch-plan.md` §4의 생성 시점 시딩, `base/blocker-plan.md`
*"호출되는 즉시"* 등록, 아래 ":With도 새 State 노드로 확정" 절.
관리 부담이 작다는 **결론 자체는 유지된다** — 근거가 "엣지가 적다"에서
**"엣지가 노드당 자기 상류 수만큼으로 유계이고 전부 weak-키"**로 바뀔 뿐이다.
다이아몬드에서 중복 재계산을 막는
것도 **노드별 캐시**(위 "다이아몬드 의존성은 무엇이 푸는가" 절)라 체인
전체가 링크드일 것을 요구하지 않고 각 노드가 자기 구독자 목록 + 자기
캐시만 가지면 되는 것이라, 이 결정과 무관하게 그대로 유지됨. 구현은
@ -314,7 +374,7 @@ Observer와 동일한 패턴(외부 weak table, `{[child] = true}` 류)으로
유일한 중복 방지 수단이 되면서 "State는 캐싱하는 존재"라는 근거가 더
강해짐.
### ⚠️ 미해결 — 중간 State가 살아남는가(구독 엣지의 방향성) (2026-08-18 구현 전 QA에서 제기, **M2 착수 전 결론 필요**)
### ⭐ 해소됨 — 중간 State는 `_hold`로 살아남는다(구독 엣지의 방향성) (2026-08-18 제기, **2026-08-25 확정**)
**사용자가 지목한 미검증 항목**: *"확인해봐야 하는게 State -> State ->
State -> Observer Leaf Bind 에서 중간 State 는 참조되지 않아도 사라지지
@ -340,11 +400,32 @@ With 등이 있는 경우 parent 와 연결된 상대를 자기 자신에 가지
없어서** 그런 우연한 캡처가 없다 — 그래서 "우연"에 기대면 안 되고 방향성을
불변식으로 못박아야 한다.
**해야 할 일**: (a) 이 방향성(상류 strong / 하류 weak)을 이 문서의
불변식으로 명문화할지 결정, (b) `luau-test`에 실측 스파이크 추가
(`07-relate-weak-table-gc.luau`가 연쇄 GC를 이미 다루므로 그 옆에).
**미검증 상태로 M2에 착수하면 안 되는 항목** — 아래 "결론"의 "관리 부담은
작음"은 이 항목이 닫히기 전까지는 잠정이다.
**⭐⭐ [2026-08-25 확정] `_hold` 불변식으로 닫혔다.** **사용자 확정**:
*"단순히, 각 state 들이 상위 State|Source 를 홀드하는 `_hold` 를 놓는것으로
바로 해결된다. 당연히 후행은 선행 요소들이 있어야하기 때문. 선행 state 가
후행 state 를 얻으려 하는건 UB이므로 가능한 일이다.(이건 릴레이션도 아니라
gc되긴 하지만.)"*
| 방향 | 강도 |
|---|---|
| 하류 State → 상류 State/Source (`_hold`) | **강함** |
| 상류 → 하류 (구독자 집합) | weak-키 |
- **모든 파생 노드**(`:With`/`:Compute`/`:Gate`/`:Block`)가 자기 상류를
`_hold`에 강하게 담는다 — `:Compute`처럼 클로저가 **우연히** 캡처하는
것에 기대지 않는다(`:With`의 pass-through 노드엔 그 우연이 없다).
- 체인은 **말단(Observer/Effect/leaf)이 살아 있는 동안** 통째로 살아 있고,
말단이 죽으면 통째로 수거된다. `Relate`가 아니므로 순환도 안 만든다.
- **상류가 하류를 얻으려는 것은 UB**라 반대 방향 강참조가 필요 없다.
- **따름정리 둘**(7라운드 `H-93`/`H-98`): 루트 `Epoch`(Source)가 하류보다
먼저 수거될 수 없으므로 *"`:Refresh()`가 `false`를 줘 낡은 값을 최신이라고
확신한다"*는 경로가 생기지 않고, `:Subscribe()`의 공개 계약 두 문장
(*"참조를 아무 데도 안 담아도 정상"* / *"GC되지 않고 영원히 계속 실행됨"*)이
서로 모순 없이 성립한다.
- **남은 것은 실측 스파이크 하나**`luau-test`에 "상류 strong / 하류 weak"
불변식을 음성 대조군까지 확인하는 파일을 추가한다
(`07-relate-weak-table-gc.luau`가 연쇄 GC를 이미 다루므로 그 옆에).
**M2 착수 게이트는 아니다**(`question.md` 최우선 절에서 내려갔다).
**결론**: 노드별 캐시 유지(현재 모델) 유지, 플래튼 기각. Modifier가
플래튼+클론을 쓰는 건 애초에 캐싱이 필요 없는 정적 데이터라 성립하는
@ -431,7 +512,18 @@ Tag/Modifier의 클론은 호출 즉시 결과가 확정되는 값이라 "-ed"(
전부 실제 노드로 두면 코드상의 호출 체인이 그래프 엣지와 1:1로 그대로
대응됨. 빌더로 만들면 그래프 툴이 "이건 노드가 아니라 나중에 갈라지는
지점"이라는 가상의 분기 모양을 따로 합성해야 함 — 그럴 이유가 없음.
2. **공유 캐시를 못 타고 중복 계산이 생김. [2026-08-14 근거 재작성]**
2. **엣지 수와 에포크 부기가 늘어남. [2026-08-25 근거 재작성, 7라운드 `H-82`]**
**여기 한때 "공유 캐시를 못 타고 중복 계산이 생김"이라 적혀 있었는데
`:With`엔 성립하지 않는다** — 같은 절이 `:With`를 *"계산 함수는 없고 값은
`self`를 그대로 통과(pass-through)"*로 확정하므로 **공유될 계산 자체가
없다**. 2026-08-14 재작성이 "근거가 더 강해졌다"면서 실제로는 정확도를
낮춘 자리다. 유효한 근거는 이것이다 — 빌더로 누적하면 최종 소비자마다
**자기 몫의 엣지 집합과 `EpochMap` 부기를 따로** 들게 되어, 실노드
하나가 그걸 공유하는 것보다 등록/판정 비용이 소비자 수만큼 늘어난다.
**결론(빌더 기각)은 안 바뀐다** — 근거 1·3이 그대로 유효하다.
아래 옛 서술은 `:Compute`에 대해서는 여전히 맞는 이야기다:
[옛 근거 — `:Compute`엔 유효, `:With`엔 무효]
원래 이 항목은 "invalid 플래그로 다이아몬드 중복 워크 방지" 장치를
근거로 들었으나 **그 장치는 폐기됨**(위 "전파 모델 확정" 절 정정) —
근거를 실제로 유효한 것으로 바꿔 적음. With가 진짜 노드면
@ -766,7 +858,9 @@ someSource:Compute(computeFn)
가능해져 버린다"는 이전 우려는 이걸로 근본적으로 해소(그런 API 자체가
없음).
- **[정정, 2026-08-06 후속 세션] 값을 쓰는 경로는 `store.key = value`
(`__newindex`)가 아니라 `store.key:Set(value)`로 전환됨** — 이유와
(`__newindex`)가 아니라 `store.key:Set(value)`로 전환됨** — **[2026-08-25]**
같은 날 오전에 대입 문법을 되살렸다가 **철회**했다
(`archive/store-value-field-redesign-withdrawn.md`). 이유와
상세는 `base/store-plan.md`의 "Store 값 설정 문법" 절 참고(요지:
Source가 State를 만족하는 구조로 바뀌며 레코드 타입 읽기/쓰기 대칭을
맞추려면 대입 문법을 포기해야 함 + `=`가 암시하는 "즉시 커밋"이 실제
@ -783,8 +877,10 @@ someSource:Compute(computeFn)
`Ref()`로 안 만들어질 특별한 이유는 없었고(이전 절에서 API 모양만
다루고 생성자를 명시 안 해서 생긴 공백), `architecture.md`의 "복사(clone)
구현 지양, 팩토리 함수로 대체" 원칙과도 정확히 일치. `Store({defaults})`
같은 스타일로 지원(`defaults`는 선택 — 안 주고 `Store()`만 호출해도
됨, 순수 편의용 초기값 템플릿).
같은 스타일로 지원. **[2026-08-25 정정]** `defaults`는 더 이상 "선택 —
순수 편의용 초기값 템플릿"이 아니다 — **명시적 초기화**가 확정되며
`defaults`가 곧 **선언 키 집합**이 됐다(옛 lazy `__index` 폐기). 무인자
`Store<<{}>>()`는 빈 타입일 때만 유효하다(`base/store-plan.md`).
- **[보강, 2026-08-09 열한 번째 세션] `Source(default)`/`Ref(default)`의
`default` 인자가 "선택"이라는 서술은 정확히는 `T``nil`을 포함할 때만
성립함 — 생략하면 실제로 `nil`이 그 자리를 채우기 때문.** `Source()`
@ -801,6 +897,20 @@ someSource:Compute(computeFn)
없이 생성하는 건 사용자 실수, 타입으로 막을 수 있으면 막고 안 되면
UB로 문서 경고).
### ⭐ `Source:Set(v)`는 동일값이어도 항상 갱신하고 emit한다 (2026-08-25 확정, 7라운드 `H-68`)
지금까지 코퍼스 어디에도 이 경우가 안 적혀 있었다. **확정**: `v`가 현재
값과 `==`여도 `Revision`을 갱신하고 정상적으로 emit한다.
- 판정이 **값 동등성이 아니라 리비전**이라는 `Epoch` 모델
(`base/state-epoch-plan.md` §2)과 일관된다.
- 더 중요한 건 **테이블 값**이다 — mutate한 뒤 같은 테이블을 다시
`Set`하는 것이 `==`로 dedup되면 **변경이 조용히 증발**한다. 아래
`:Emit()` 절이 다루는 것과 정확히 같은 상황인데, `Set`이 dedup하면
사용자가 그 구분을 항상 의식해야 한다.
- 불필요한 파동을 접는 일은 이미 **하류**가 한다 — `EpochMap` 판정과
게이트(`base/gate-plan.md`).
## Source 값을 직접 mutate한 뒤 전파 — `:Emit()` (2026-08-06 후속 세션, 호출부 정정)
**결정**: Source가 들고 있는 값을 새 값으로 교체하지 않고 제자리에서
@ -892,11 +1002,34 @@ State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양
방식이 완전히 통일됨. `:With`/`:Compute` 자체를 대신 호출해주는
자동화가 아니므로, 여전히 팩토리 본문 안에서 `:With`/`:Compute`를
직접 호출하는 건 팩토리 작성자 몫.
- **⭐⭐ [2026-08-25 확정, 7라운드 `H-94`] 팩토리는 함수이거나 "지정된
필드를 가진 객체"다 — `__call` 테이블은 안 받는다.** `Debounce{...}` /
`Throttle{...}``__call` 테이블을 돌려주면
`state:Apply(Debounce{...})`가 **타입에러**다(`luau-analyze` 실측 —
`__call` 테이블은 함수 타입 자리에 안 들어간다. 런타임은 멀쩡하고
타입만 막히므로 `--!nocheck` 스파이크에선 안 드러난다). 같은 실측이
타입 레벨 `__call`의 다른 한계도 보였다 — `self`를 못 받고,
`typeof(f<<T>>)`로 타입 인자를 넘기는 것도 실패한다.
- **확정**: 애플리커티브 팩토리는 **`__call`이 아니라 지정된 필드**로
자기를 노출한다. **사용자 판단**: *"아에 어플리커티브 펑터로써,
`__call` 이 아닌 다른 필드로 들어가는게 맞아보여요. 외부에서 직접
`()` 호출하는건 의미 없게 둬야해요."*
- **부수 효과**: `Blocker`를 슈가로 못 두던 이유도 같이 풀린다 —
`Debounce`/`Throttle`/`Blocker`가 전부 같은 계약을 만족하게 된다.
- **함수와 콜러블의 유니온으로 여는 안은 기각** — 필드로 받으면
유니온도 캐스트도 필요 없다. 필드 이름과 정확한 시그니처는 구현 시
정한다.
- **구현 비용 거의 0**: Modifier와 달리 State/Source는 제네릭 `__index`
필드 setter를 즉석 합성하는 메커니즘이 없어서(고정된 메소드 표면만
존재), Modifier의 `Apply`처럼 "필드 이름으로 예약해야 하는" 충돌
자체가 없음 — 그냥 고정 메소드 하나 추가하는 것.
- **타입은 `factory: (State<T>) -> U): U`로 완전히 열어둠** — Modifier의
- **타입은 반환 쪽을 완전히 열어둠****⚠️ [2026-08-25 정정, `H-94`]**
여기 한때 `factory: (State<T>) -> U): U`라 적혀 있었는데, 그 시그니처는
**함수만** 받으므로 위 `H-94` 항목이 확정한 "지정된 필드를 가진 객체"
형태를 **거부한다**`state:Apply(Debounce{...})`가 그대로 타입에러다.
파라미터는 **함수 또는 그 필드를 가진 객체**를 받고 반환 `U`만 열어둔다
(정확한 필드 이름과 시그니처는 구현 시 정한다). 아래 논거는 **반환 쪽**에
대한 것이라 그대로 유효하다 — Modifier의
`Apply``factory: (M) -> M`으로 같은 타입을 유지해야 체이닝이
이어지지만, State의 `:Apply`는 팩토리가 State가 아닌 값(예: 최종
요약된 plain 값)을 반환해 반응형 그래프를 벗어나는 탈출구로 쓰는 것도
@ -1099,6 +1232,13 @@ override할 자리를 구조적으로 열어두는 것.)
멈춘다")을 만족시키는 가장 단순한 도구 — 별도 콜백 로직 없이 그냥
이 State가 계속 재계산되게만 강제하고 싶을 때 씀. 문서화만 확실히
하면 별문제 없음(사용자 판단).
- **⭐ [2026-08-25 정정, 7라운드 `H-61`] 내부 콜백은 no-op가 아니라
`function(self) self:Get() end`이다.** 전파가 push-invalidate /
pull-recompute라 `Get()`을 안 부르면 재계산이 아예 안 일어난다 —
같은 절이 바로 위에서 *"값을 안 실어줌 — 반드시 `Get()`을 다시 해야
함"*이라 못박고 있으므로, no-op 콜백이었다면 이 유틸은 자기 용도
(`previous` 패턴의 mutate 로직을 계속 돌게 하기)를 **하나도 못
한다**. 이름("항상 관측")과도 이쪽이 맞는다.
### Slot 생존 확인 — 별도 메커니즘 아님, `canExecute` 재사용으로 확정
@ -1125,6 +1265,24 @@ no-op. 한때 검토했던 "`isInit=false`면 허용, `isInit=true`+생존확인
`bindLifetime`(`base/lifecycle-pattern.md`)으로 명명됨 — 별도 메커니즘
두 개가 아니라 같은 것의 명명 전/후 표현.
**⭐⭐ [2026-08-25 신설, 7라운드 `H-58`/`H-59`] `:WeakSubscribe()` /
`:WeakUnsubscribe()` — 약하게 등록하는 짝.** `Weak` 쪽이 **프리미티브**이고
평범한 `:Subscribe()`는 그 위에 "GC 안 되도록 킵" 하나를 더 얹은 것이다
(**사용자 확정**: *"동작 자체는 Weak 아닌것과 동일하게 가고, 가드도 동일하나
단순히 gc 안 되도록 킵 해주는 부분만 제거된 함수가 됩니다"*). 즉
`Subscribe() = WeakSubscribe() + 강한 레지스트리에 킵`이고 구현이 한 벌이다.
- **자료구조**: 전역 레지스트리에 **약하게** 들어간다. 살려두는 책임은
**잡고 있는 쪽**에 있다 — `Effect`가 자기 내부 Observer를 `_deps`
강하게 들고 있는 게 그 예다(`base/effect-plan.md`의
"확정 구조 — 강한 주인은 항상 `Effect`" 절).
- **왜 필요한가**: `Effect`가 dep마다 Observer를 만들 때 평범한
`:Subscribe()`를 쓰면 전역 레지스트리가 그 Observer를(따라서 `Effect`를)
영원히 붙든다. 그렇다고 바인드/언바인드마다 등록·해제하면
*"등록 시점에 즉시 1회 실행"*에 걸려 **바인드마다 `Rerun`이 dep 수만큼
돈다**. `WeakSubscribe`면 둘 다 사라진다.
- `Ref` 쪽의 짝은 `ref:WeakCallback(fn)`이다(`base/ref-plan.md`).
**해결**: 명시적 `:Subscribe()`/`:Unsubscribe()`를 추가로 지원. 이건 새
설계가 아니라 PA님 코드 교차검증(아래 라이프사이클 절)에서 이미 예고해둔
확장 지점을 실제로 채우는 것 — "나중에 GC만으로 정말 부족한 케이스가
@ -1383,23 +1541,49 @@ function ObserverEffectLeafHandler.process(inst, k, v, index)
Dispatch.setOffsetSource(inst, k, None)
Dispatch.setLength(inst, k, 0, inst)
local old = relate:GetStrong(inst, k)
local old = relate:GetWeak(inst, k) -- ⭐ [2026-08-25 `H-71`] 쓰기가 SetWeak이므로 읽기도 Weak
-- (안 맞추면 old가 항상 nil이라 dedup이 통째로 죽는다)
if old ~= v then -- 이미 같은 값이 이 자리를 차지 중이면 재바인딩 skip
bindLifetime(inst, v) -- Effect는 내부적으로 자기 Observer까지 cascade(`base/effect-plan.md`)
bindLifetime(inst, v) -- [2026-08-25 정정] Effect도 **핸들 하나만** 바인드된다 —
-- dep은 생성자에서 `Weak*`로 걸려 있고 발화는
-- `canExecute(handle)`이 게이팅(`base/effect-plan.md`)
end
relate:SetStrong(inst, k, v)
relate:SetWeak(inst, k, v) -- ⭐ [2026-08-25, 7라운드 `H-71`] Strong 아님 — 아래 참고
return function(nextValue)
if nextValue ~= v then
unbindLifetime(v)
-- ⭐ [2026-08-25, 7라운드 `H-57`] 값 교체는 파괴에 준한다 — 그 Effect는
-- 다시 오지 않으므로 cleanup을 여기서 소진 호출한다.
if isEffect(v) then v:_consumeCleanup() end
-- [`RefLeafHandler`와 같은 주의] relate 정리는 반드시 이 분기 *안*에서만 —
-- 밖에 두면 spurious 재발행(nextValue == v)에서도 기록이 지워져 곧바로
-- 이어지는 process가 `old ~= v`를 항상 참으로 보고 dedup이 무력화됨.
if relate:GetStrong(inst, k) == v then relate:SetStrong(inst, k, nil) end
if relate:GetWeak(inst, k) == v then relate:SetWeak(inst, k, nil) end
end
end
end
```
**⭐ [2026-08-25 신설, 7라운드 `H-71`] dedup 기록은 `SetWeak`이다.**
`SetStrong`으로 두면 **값이 `inst`를 되참조할 때 100% 샌다** — 커밋된
`Relate.luau`로 50/50 누수가 실측됐다(`base/relate-plan.md`의 슬롯별 강약
표). dedup은 이 절이 스스로 밝히듯 **순수 성능 최적화**라, weak로 낮춰
엔트리가 조기 소실돼도 "dedup을 한 번 놓친다"까지가 최대 손해다. `v`
`gchold`가 이미 강하게 잡고 있고, `relate-plan.md`**"다른 곳에서
안전하게 유지되는 것은 항상 `SetWeak`"** 규칙에도 그대로 맞는다.
`RefLeafHandler`도 같은 정정을 받는다(`base/ref-plan.md`).
**⭐ [2026-08-25 신설, 7라운드 `H-57`] 값 교체 retract는 cleanup을 부른다.**
`base/effect-plan.md`가 확정한 *"`unbindLifetime`은 cleanup을 부르지
않는다"*는 **포탈 언마운트**를 보고 정한 것인데, `unbindLifetime`의 호출부는
셋이다 — 포탈 언마운트 / 파괴 직전 / **값 교체 retract**. 앞의 둘은
cleanup을 안 불러도 되지만 셋째는 **파괴에 준한다**(그 `Effect`는 다시 안
온다). 안 부르면 `Frame { effectState }`에서 `effectState:Set(E2)` 뒤에도
`E1`의 타이머가 영원히 돈다 — React로 치면 `useEffect` 클로저가 바뀌었는데
이전 cleanup을 안 부르는 것. `_consumeCleanup()`이 **읽고 → 지우고 →
실행**이라 파괴 경로와 이중 호출이 없고, `unbindLifetime`의 계약 자체는
안 건드린다.
## PA님 코드와의 교차검증(2026-08-04 4차 라운드) — 둘 다 기존 확정 유지
`.claude/initreq/artworks/EventDrivenProgramming/`(Connection/Event/

View file

@ -164,12 +164,33 @@ type EpochSet = { [Epoch]: true } -- 배열이 아니라 집합이다 (아래
EpochMap() -> EpochMap
EpochMap:Update(Epoch | EpochSet) -> boolean -- "뒤로 전파가 필요한가"
EpochMap:Update(Epoch | EpochSet) -> boolean -- "뒤로 전파가 필요한가" (읽고, 비교하고, 덮음)
EpochMap:Peek(Epoch | EpochSet) -> boolean -- 같은 비교를 하되 **덮지 않음** (2026-08-25 신설)
EpochMap:Refresh() -> boolean -- 자기 키 전부를 라이브로 다시 읽음
EpochMap:Sync(Epoch | EpochSet) -- 읽지 않고 쓰기만 (반환값 없음)
EpochMap:TrackFrom(other: EpochMap) -- other가 추적 중인 키를 넘겨받아 라이브 리비전으로 채움
```
**⭐⭐ [2026-08-25 신설, 7라운드 `H-72`] `:Peek` — 갱신하지 않고 비교만 한다.**
`GateNode`가 §4의 수신 규칙 1~3을 그대로 돌려면 `emitChanged`가 필요한데,
그걸 얻는 유일한 통로였던 `:Update`**정의상 읽고 나서 덮는다**. 그런데
같은 §4의 게이트 예외는 *"`emitEpochMap:Update`를 수신 시점에 부르지
않는다"*(유보 중엔 아직 안 던졌다는 맵의 뜻을 지키려고)라, **두 요구를
동시에 만족할 연산이 표면에 없었다.** 나머지 표면도 전부 쓰기를 한다 —
`:Refresh`는 자기 키를 다시 읽어 **갱신**하고, `:Sync`는 **쓰기 전용**,
`:TrackFrom`은 키를 넘겨받아 **채운다**.
```lua
-- GateNode:_receive
local valueChanged = self.valueEpochMap:Update(from)
local emitChanged = self.emitEpochMap:Peek(from) -- 갱신 안 함
```
`:Update`가 이미 `{읽기, 비교, 쓰기}``:Peek`은 그 앞 두 개만 쓰는 것이고
내부 코드 공유가 쉽다. 플래그(`Update(from, write)`)로 두는 안은 기각 —
호출부에서 "안 쓴다"가 안 보이고 boolean 파라미터가 늘 그렇듯 읽기가
나빠진다.
**⚠️ [2026-08-22 정정] 여러 개를 넘길 때는 `{Epoch}`(배열)가 아니라
`{[Epoch]: true}`(집합)다.** 여기 한때 `{Epoch}`로 적혀 있었는데, Luau에서
그건 `{[number]: Epoch}` 배열이라 **실제로 넘어오는 게이트 배치와 타입이
@ -328,9 +349,68 @@ end
폐기된 옛 dedup의 "영구 침묵"과 같은 계열이다
(`archive/invalidate-dedup-propagation-reversed.md`).
### ⭐⭐ `rawInvalid` 불린은 **캐시 카운터 쌍**으로 교체됐다 (2026-08-25, 7라운드 `H-85`)
**문제**: 아래 "재계산이 끝나면"이 확정한 `rawInvalid = false`가 **재계산
*도중* 도착한 무효화를 지운다.** `fn` 실행 중에 상류가 `Set`되면 규칙 1이
`rawInvalid = true`를 세우는데, `fn`이 반환한 직후 꼬리가 그걸 무조건
`false`로 덮는다 — 캐시가 **다음 `Set`까지 영구 stale**이고, 한 세대를
조용히 건너뛰는 형태라 관측이 어렵다. 재계산 중 상류 쓰기는 흔하다(다른
Store 필드에 결과를 적어두는 관용구, 헬퍼의 lazy 초기화, `fn`이 yield하는
사이 타이머가 `Set`하는 경우 — yield 금지 불변식은 `Dispatch.process`/
`attachSlot` 체인 **안**에만 적용된다).
**확정**: `rawInvalid: boolean`**`cacheTargetCount` / `cacheCurrCount`**
두 필드로 바꾼다. **사용자 확정**: *"state 는 epoch 를 구현해선 안 돼.
중간이지, 초기 값 컨테이너 계층은 아니거든. 따라서 cache count 를 넣을것을
추천해. `cacheTargetCount`, `cacheCurrCount` 를 놓는게 맞지 않을까?"*
```lua
-- 생성 시
self.cacheTargetCount = 0
self.cacheCurrCount = nil -- 아직 계산된 적 없음 → 항상 다름
-- 무효화 (아래 규칙 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`이 반드시 재계산한다.
- **`fn`이 던지면** `cacheCurrCount`가 안 갱신되므로 *계산된 적 없는
캐시를 유효하다고 확신*하는 일이 없다 — **사용자 지적**: *"특히 에러가
난다고 하면, 다시 계산 안하고 이전 결과를 다시 쓰겠네?"* (그래서
"`rawInvalid = false`를 `fn` 앞으로 옮긴다"는 한 줄짜리 대안은
**불충분하다**.)
- 증가는 §2가 `Source.Revision`에 확정한 **`bit32.bnot(-n)` 랩** 그대로다 —
비교가 `~=`뿐이라 랩이 무해한 것도 같고, uint32 안에 머무르므로 평이한
`+1`이 갖는 2^53 포화 지점 자체가 없다.
- **⚠️ 초기값은 `curr = nil`이어야 한다 — 두 숫자를 나란히 두면 안 된다.**
이 갱신은 **감소**다(§2의 실측 표: `1 → 0 → 4294967295`). 그래서 한때
적어뒀던 `target = 1, curr = 0`은 **첫 무효화에서 `target``0`이 되어
`curr`와 같아진다** — 계산된 적 없는 캐시를 "유효"로 판정해 다음 `Set`
올 때까지 그 값을 그대로 돌려준다(2026-08-25 `/code-review high` 발견).
`curr = nil`이면 어떤 숫자와도 다르고 **갱신 방향이 바뀌어도 안 깨진다.**
필드 타입은 `number?`이고, 첫 재계산 이후로는 계속 숫자다.
- **State는 여전히 `Epoch`를 구현하지 않는다** — 이 카운터는 **자기 재계산
부기**이지 남이 키로 삼는 리비전이 아니다(§4가 State dep에 대해
`TrackFrom(dep.valueEpochMap)`을 쓰는 것과 일관).
- 아래 두 절(`재계산 판정` / `재계산이 끝나면`)의 `rawInvalid`는 전부 이
카운터 비교로 읽을 것.
### 재계산이 끝나면
- **`rawInvalid = false`**, 그리고 **`valueEpochMap`은 자기가 읽은 상류
- **`rawInvalid = false`**(위 카운터로는 `cacheCurrCount = gen`), 그리고 **`valueEpochMap`은 자기가 읽은 상류
전부에 대해 갱신한다**(발행 `Epoch` 항목만이 아니다). 맵의 뜻이 "내 값이 이
`Epoch`에 대해 최신인가"이므로, 방금 계산한 값은 정의상 **모든** 상류에 대해
최신이다. 사용자: *"invalid 에 대한 계산을 위한 count 테이블은 단순히 전부
@ -395,6 +475,20 @@ State 층 dedup이 못 닫던 갭이다 — `Effect`가 자기 맵을 들면 그
재계산한다.
- 맵 하나당 객체 하나가 늘지만(State당 둘), 옛 모양도 테이블 둘이었으므로
컴포지션으로 바뀌며 늘어난 비용은 메소드 디스패치뿐이다.
- **⭐ [2026-08-25 추가, 7라운드 `H-92`] 전파 루프의 구독자 스냅샷은 여기
셈에 안 들어가 있었다.** `base/source-state-plan.md`의 "전파 루프 — 확정
의사코드" 절이 확정한 대로, 순회 중 등록이 미정의라 매 발화마다 구독자
집합을 배열로 복사한다 — 즉 **파동이 지나는 노드 수만큼 배열 하나씩**이
할당된다. §2가 테이블 리비전 방식을 기각한 근거가 *"`Set` 한 번마다
테이블 하나를 할당"*이었으므로 이 비용은 정직하게 적어둔다. **정확성
문제는 아니고**(스냅샷 자체는 필수) 이 문서의 결정을 뒤집지도 않는다 —
테이블 리비전은 **노드마다 상시** 새 테이블을 만드는 것이고 이건 **발화
경로에만** 생기는 임시 배열이다.
- **게이트 통과 모드의 할당**(7라운드 `H-69`): `base/gate-plan.md` 4번이
확정한 대로 게이트는 통과시킬 때도 `withheld`에 넣었다가 flush에서
새 weak 테이블로 스왑하므로 **emit마다 테이블 하나**를 쓴다. 역시
정확성 문제가 아니라 구현 시 최적화 여지(통과 모드면 스왑 없이 넘기기)로
적어둔다.
## 8. 구현 시 확인할 것
@ -409,6 +503,13 @@ State 층 dedup이 못 닫던 갭이다 — `Effect`가 자기 맵을 들면 그
던지는게 맞다. 상류의 상태를 물어보므로 그러함. 이전과 다른게 없다고
생각한다."* — 선언 안 한 Source를 클로저로 읽는 건 **옛 모델에서도 똑같이
stale**이었고 이 변경이 악화시키는 게 없다.
- **⚠️ [2026-08-25 범위 정정, 7라운드 `H-91`] 위 인용의 *"항상 state 는
get 이 최신"*은 **선언한 의존성에 대해서**다.** 문장 그대로 읽으면
코퍼스에 **의도적 반례**가 있다 — `base/tween-plan.md``Animate`
*"`info.Style`이 State여도 이 내부 `:Compute`의 trailing deps로 안
넘어가므로 구독 목록에 안 걸림"*이라며 **미선언 읽기를 설계로** 쓴다.
바로 위 문장이 이미 "선언 안 한 것은 옛 모델에서도 stale"이라 말하고
있으므로 결론은 안 바뀌고, 범위만 좁혀 적는다.
- **동적 의존성**은 `valueEpochMap`이 보수적 상위집합이 된다 — 틀리진 않고
재계산이 조금 더 잦아질 뿐이다.
- **`Source:Emit()`**(값을 제자리에서 mutate하고 알리는 경로)은 `Revision`

View file

@ -9,8 +9,13 @@
> **`base/source-state-plan.md`**.
**상태**: base — Store가 부작용을 허용한다는 핵심 결정, "이름 붙은 Source
모음"이라는 정의, eager+lazy 생성, `store.key` dot-access 타이핑, `:Set()`
문법 전환까지 전부 확정. 원본: `.claude/initreq/raw-userinput.md`
모음"이라는 정의, `store.key` dot-access 타이핑까지 전부 확정.
**⭐ [2026-08-25] 두 가지가 바뀌었다** — (1) 생성이 **명시적 초기화**로
확정되고 옛 lazy `__index`가 폐기됐다, (2) 타입은 **타입 함수를 안 쓰고**
평범한 레코드로 짓는다(`WrapStore`/`ProcessStoreType` 폐기). 같은 날
"`store.key`를 값으로" 재설계를 넣었다가 철회한 경위는
`archive/store-value-field-redesign-withdrawn.md`. 원본:
`.claude/initreq/raw-userinput.md`
"store는 부작용을 허용함" / "스토어는 스토어를 저장 가능한가" 절.
## Store는 부작용을 허용하는 게 기본 디자인
@ -39,53 +44,74 @@ purity-and-effects-plan.md`와 연결됨).
어떻게 되는가"는 별도 메커니즘 없이 `canExecute` 재사용으로 해결됨 —
`base/source-state-plan.md`의 "Slot 생존 확인" 절이 소스.
## Store = Source들의 이름 붙은 모음 (eager + lazy 생성)
## Store = Source들의 이름 붙은 모음 (명시적 초기화)
`store.a`처럼 키로 접근하면 **이미 만들어진 Source가 있으면 그대로 반환,
없으면 그 자리에서 만들어 저장한 뒤 반환** — 더 이상 별도 State wrapper를
매번 만들거나 따로 캐싱하지 않음(Source 자체가 이미 State를 만족하므로
wrapper 계층 자체가 불필요해짐, `base/source-state-plan.md`의 "Source가
State를 만족함" 절).
> **⭐⭐ [2026-08-25] 같은 날 두 번 바뀌었다 — 최종은 이 절이다.**
> 오전에 "`store.key`가 값이고 `store:Of(k)`가 프리미티브"라는 재설계를
> 넣었다가 **같은 날 철회**했다. 철회된 시도의 원문과 이유는
> `archive/store-value-field-redesign-withdrawn.md`. 그 시도가 남긴 것은
> **명시적 초기화**(eager/lazy 이중 모델 폐기) 하나이고, 나머지(값 필드,
> `__index`/`__newindex` 슈가, 팬텀 필드, `index<>`/`keyof<>`)는 전부
> 되돌렸다.
- **`defaults`는 선택**(안 줘도 됨, 순수 편의용 초기값 템플릿) —
`Store({defaults})`가 내부적으로 `{[key] = Source(default), ...}`
다름없게 됨.
- **[정정, 2026-08-07] "Store 생성 시 전부 eager하게만 만들어진다"는 이전
서술은 부정확 — eager와 lazy가 둘 다 필요하다.** Luau 타입은 런타임에
강제되지 않으므로 `Store<<SomeType>>()`처럼 `defaults` 없이 만든 뒤
`.Key:Set(v)`를 부르는 경우, `__index`가 "없으면 그 자리에서 만들어
저장"까지 해주지 않으면 `.Key``nil`이라 크래시남. 그래서 **Store
생성 시점의 eager 생성**(각 `defaults` 키마다 미리 만들어둠)과
**`store.key` 접근 시점의 lazy 생성**(아직 없는 키를 그 자리에서 만들어
저장, 이후 재접근은 재생성 없이 그대로 반환)이 **둘 다** 필요함.
- **[2026-08-18 구현 전 QA, 2026-08-19 M0 실측으로 해소] lazy 생성이
오타/동적 키로 Source를 무한정 누적하는 트레이드오프는 그대로 수용하고,
방어선은 런타임이 아니라 타입에 둔다.** 사용자 판정: *"Store<{ field:
type }> 상 없는 네임에는 타입 시간에 Source 가 없는것으로 나와 타입
에러만 나면 됩니다. 아마 지금 설계가 그럴것이예요"* — 즉
`Store<{field: T}>`로 선언된 Store에 없는 이름을 쓰면 `type function`
합성한 결과 타입에 그 프로퍼티가 없어 **타입 에러**가 나야 한다.
**[2026-08-19 실측 완료]** 맞았음 —
`luau-test/done/21-type-store-undeclared-key-rejected.luau`
`ProcessStoreType`(`16`과 동일 type function)의 결과 타입에 미선언 키로
접근하면 정확히 `TypeError` 2건(읽기·메소드 체이닝)이 나고, 선언된 키
3개는 클린임을 확인. 런타임에 굳이 이름을 받아야 하는 경우는
`:GetDynamic`(아래 "타입 추론 문제" 절)이 정식 창구.
- **`defaults` 테이블 원본을 나중에 mutate해도 UB가 아님** — 라이브 백킹
스토리지가 아니라 "아직 안 만들어진 Source를 만들 때 참고하는 초기값
템플릿"으로만 반복 참조되기 때문(`bind-system-plan.md`에 남아있던
"defaults 테이블 직접 mutate는 UB"라는 옛 서술은 2026-08-07에 정정됨).
별도 `__values`류 그림자 실값 저장소도 불필요 — Source 객체 자체가
저장소 역할을 함.
- **구현 스케치(2026-08-07, 성능 근거): eager 생성은 `table.clone(defaults)`
후 그 결과를 순회하며 각 슬롯을 `Source(v)`로 교체하는 모양이어야 함**
(`local sources = table.clone(defaults); for k, v in sources do
sources[k] = Source(v) end` 류) — 빈 테이블을 새로 만들어 키를 하나씩
넣는 것보다, `table.clone`으로 원본의 해시/배열 슬롯 구조를 그대로
재사용하는 쪽이 Luau VM 입장에서 더 쌈(직접 해시 슬롯을 처음부터
구성하는 것보다 기존 슬롯을 복제하는 게 저렴). `Source()`(인자 없이
호출)는 `Source(nil)`과 동치 — `defaults`에 값이 없는 키를 `store.key`
접근 시점에 lazy 생성할 때 이 무인자 형태를 씀.
**Store의 타입 인자에는 `Source<T>`를 직접 쓰고, `defaults`에도 `Source(v)`
직접 넣는다.**
```lua
local store = quad.Store<<{
hp: Source<number>,
name: Source<string>,
}>>({
hp = Source(100),
name = Source(""),
})
store.hp:Get() -- 평범한 레코드 필드 접근 → Source<number>
store.hp:Set(5)
store.hp:Compute(function(s) ... end)
```
- **`store.key`는 평범한 레코드 필드다** — `Store<T>``T`(그 자체로
`{hp: Source<number>, ...}`)를 그대로 포함하므로 **타입 함수가 하나도
안 든다.** 마법이 없고, 읽기/쓰기 의미론이 `Source`의 기존 계약
(`:Get()`/`:Set()`) 그대로다.
- **⭐ 명시적 초기화가 기본** — 선언한 키는 생성 시 `Source`를 준다.
`defaults`에 없는 키는 **필드 자체가 없어** 타입에서 걸리고, 런타임에도
`nil` 역참조로 즉시 드러난다. 옛 lazy `__index`(없는 키를 그 자리에서
만들어 저장)는 **폐기**됐다 — **사용자 근거**: *"lazy 로 만들어낸다는
발상 자체가 약간 문제가 있어요. Set 을 안 해주면, 초기 값이 타입에
어긋날 수 있거든요. 애초에, `Source<number>` 인데, nil을 조용히 가지고
있을수도 있고, 타입으로 못 막네요."*
- **부수 효과**: 선언 키 집합의 **런타임 소스가 `defaults` 하나**로
확정된다. 그래서 `store:Names()``pairs`로 성립한다 — 옛 lazy
모델에선 키 집합이 접근 이력에 좌우돼 0개/1개/2개로 갈렸다
(7라운드 `H-79`).
- **부모가 값을 다 안 넘겨도 되게 하려면 컴포넌트가 자기 `DEFAULTS`
채운다** — 타입에 `?`를 다는 게 아니라 **호출 규약**으로 표현한다
(**사용자 아이디어**: *"기본 값인 소스는 한 곳에 `Defaults = {}` 해두고
쓰는거죠"*). 그래야 생성자가 항상 완전한 테이블을 받는다.
- **외부에서 만든 `Source`를 나중에 끼워 넣는 표면은 없다** — 생성 시점에
넣는 게 전부다(**사용자 판단**: *"다른곳에서 생성된 Source 를 다시
넣는다는게, 가능하게 해야할 표면적 이유가 없습니다"*).
- **값이 없는 상태가 필요하면 `None`이 이미 그 자리다** — attribute를
실제로 지우는 것도 `Source<None> → None → nil`로 핸들러 계열을 타고
말단에서 set nil 된다. Store 키를 nilable로 만들 이유가 아니다.
- **구현 스케치**: 생성 시 `table.clone(defaults or {})`로 그림자 테이블을
만든다(`table.clone`이 원본의 해시/배열 슬롯 구조를 재사용해 빈 테이블에
키를 하나씩 넣는 것보다 쌈 — 2026-08-07 성능 근거 그대로). 값이 이미
`Source`이므로 **슬롯 교체 순회가 없다**. **`or {}`가 필수다** — 무인자
`Store<<{}>>()`도 유효한데 `table.clone(nil)`
`table expected, got nil`로 죽는다(**[2026-08-25]** 7라운드 `H-83` 실측).
- **`store:Names()`** — 그 시점의 키 집합을 준다(그림자 테이블의 키). 그룹
`Attribute(...)`/`attr:NameMap()`이 이걸 요구한다
(`base/attribute-plan.md`, 7라운드 `H-79`).
**⚠️ "선언된 키"와 정확히 같지는 않다** — `defaults`의 키에 **동적 키
창구 `store:Of(name)`이 만든 것**이 더해진다(아래 "타입 추론 문제" 절).
둘의 차이는 `Of`를 쓴 Store에서만 생기고, `Of`는 "타입 보장을 포기했다"가
호출부에 드러나는 명시적 자리다. **그룹 `Attribute`에 미치는 영향**:
이미 배치된 `Attribute(store)` 바인딩은 그 시점의 `NameMap()` 스냅샷으로
구성되므로, 나중에 `Of`로 늘어난 키는 **다음 재디스패치 때** 반영된다
(`base/attribute-plan.md`의 그룹 절).
v1이 모든 값을 Store 하나에 몰아넣던 습관은 "당시 정적 타입이 없어 단순하게
쓰는 게 편해서"였다는 게 사용자의 회고적 재평가 — 지금은 타입이 핵심
@ -95,38 +121,36 @@ Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐
`Source(default)`를 쓸 것(`base/source-state-plan.md`의 "Source는 독립
공개 프리미티브로 격상" 절).
## Store 값 설정 문법 — `myStore.key = value` 폐기, `source:Set(value)`로 전환 (2026-08-06 후속 세션, 정정)
## Store 값 설정 문법 — `myStore.key = value` 폐기, `store.key:Set(value)` (2026-08-06 후속 세션, 확정 유지)
**이전 버전("v1 인체공학 유지, `__newindex` 기반 `myStore.key = value`
그대로")은 폐기됨.** `base/source-state-plan.md`의 "Source가 State를
만족함" 절 타입 설계와 맞물려 재검토된 결과:
> **[2026-08-25] 이 결정은 유지된다.** 같은 날 오전에 한 번 뒤집었다가
> (`store.key`를 값으로 만들면서 대입이 되살아났었다) **같은 날 철회**했다 —
> `archive/store-value-field-redesign-withdrawn.md`. `store.key`가 다시
> `Source<T>`이므로 아래 근거 셋이 전부 그대로 성립한다.
1. **타입 대칭성**: `store.key`가 이제 `Source<T>`를 직접 반환하는
평범한 레코드 필드(`{key: Source<number>}`)로 타이핑되는데, 레코드
필드는 읽기/쓰기 타입이 같아야 Luau 구조적 타이핑이 깨끗하게 성립함.
**`store.key = value`는 안 쓴다.** 값을 쓰는 경로는 `store.key:Set(value)`다.
1. **타입 대칭성**: `store.key``Source<T>`를 직접 반환하는 평범한 레코드
필드(`{key: Source<number>}`)로 타이핑되는데, 레코드 필드는 읽기/쓰기
타입이 같아야 Luau 구조적 타이핑이 깨끗하게 성립한다.
`store.key = value`(raw `T` 대입)를 유지하면 읽기(`Source<T>`)/쓰기(`T`)
타입이 갈려 mismatch가 남음 — `store.key:Set(value)`로 통일하면 필드
타입이 항상 `Source<T>`로 대칭적이라 문제 자체가 안 생(사용자 지적).
타입이 갈려 mismatch가 남는다 — `:Set(value)`로 통일하면 필드 타입이 항상
`Source<T>`로 대칭적이라 문제 자체가 안 생긴다(사용자 지적).
2. **의미론적 정직성**: `=` 대입 문법은 관례상 "그 자리에서 즉시 확정되는
부작용 없는 값 쓰기"를 암시하는데, quad의 실제 동작은 **lazy**
`Set`은 무효화 신호만 쏘고, 실제 재계산은 나중에 누군가 관측(`Get()`)할
때만 일어남("Emit으로 필요한 사람 있어? 하고 물어보고, 있어야 진짜
계산 시작"). 이건 `=`가 암시하는 "즉시 커밋"과 정서가 안 맞고, 메소드
호출(`:Set()`)이 "이건 프로세스를 트리거하는 연산"이라는 걸 더 정직하게
신호함(사용자 확정 논거).
3. `:Set()`은 이미 확정된 "값을 바꾸는 연산엔 `:` 체이닝 허용" 원칙(`base/
architecture.md`)에도 자연스럽게 들어맞음 — 문법 자체가 새로 생기는 게
아니라 기존 원칙의 정상적인 적용.
때만 일어난다. 메소드 호출(`:Set()`)이 "이건 프로세스를 트리거하는
연산"이라는 걸 더 정직하게 신호한다(사용자 확정 논거).
**[2026-08-25 보강]** 철회된 재설계가 이 논거를 실제로 검증해줬다 —
`store.key = v`를 되살리자 *"`.Value = 1`이 정적 쓰기처럼 보이는데
거기서 error trace가 나오면 당황스럽고 약간 마법적"*(사용자)이라는
문제가 바로 드러났다.
3. `:Set()`은 이미 확정된 "값을 바꾸는 연산엔 `:` 체이닝 허용" 원칙
(`base/architecture.md`)에도 자연스럽게 들어맞는다.
**남는 것**: `:` 체이닝 원칙은 `:Set()` 자체가 그 사례라 유지.
**[정정, 2026-08-18 구현 전 QA] `myStore "key"`(문자열 커링)는 기각됐다** —
여기엔 "동적 키 전용 미타입 폴백으로 격하돼 그대로 유지"로 적혀 있었으나,
사용자 판정은 폐기다: *"store "a" 식으로 문자열 호출하는것 또한 기각된
바임. 저러면 "a" 가 string 으로 들어가서, Source<T> 의 타입을 모르기도
하고, 우린 더이상 필요하지 않게 된 요소임."* 근거는 (a) `"a"`가 그냥
`string`으로 들어가 `Source<T>``T`를 알 수 없고, (b) dot-access +
`type function` 타이핑이 자리잡아 더 이상 필요 없어졌다는 것. 동적 키는
아래 `:GetDynamic` 항목으로 간다.
**`myStore "key"`(문자열 커링)는 기각**이다(2026-08-18 사용자 판정) —
*"저러면 "a" 가 string 으로 들어가서, Source<T> 의 타입을 모르기도 하고,
우린 더이상 필요하지 않게 된 요소임."* 동적 키는 아래 `:Of`로 간다.
`base/architecture.md`의 "복사(clone) 구현 지양, 팩토리 함수로 대체" 원칙과 함께
읽을 것 — v1의 문제는 metatable 체이닝으로 매번 새 테이블을 할당하며
@ -137,93 +161,119 @@ Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐
- `store "key"`(문자열 커링)로 `state<T>`를 오버로드 함수 타입으로 정확히
추론하려는 시도는 포기하고(그 문자열 커링 자체도 **[2026-08-18] 기각**,
위 절), **`store.key`(dot-access)를 1급 경로로 확정**
— Store 타입을 `{key: Source<number>, other: Source<string>}`류 평범한
레코드 타입으로 지으면 일반 구조적 필드 타이핑으로 자동 해결되고, 문자열
리터럴 narrowing 문제 자체가 안 생김([정정, 2026-08-06] 원래 `State<T>`
필드로 적혀있었으나 Source가 State를 만족하는 구조로 바뀌며 `Source<T>`
갱신 — `store.key = value` 쓰기 문법이 `:Set()`으로 옮겨가 이 필드가
더 이상 `__newindex`로 쓰이지 않으므로 읽기/쓰기 타입 대칭 문제도 같이
해소됨, 위 "Store 값 설정 문법" 절 참고).
**[정정, 2026-08-18] 동적 키 경로는 문자열 커링이 아니라 명시적 메소드다** —
`store:GetDynamic<<T>>(name): Source<T>`. 런타임 동작 자체는 원래도
dot-access와 같았고(lazy `__index`가 없는 이름을 만들면 그 자리에서
Source를 만들어줌 — 아래 "없는 키" 항목) 문제는 **타입**뿐이었다:
선언되지 않은 이름은 `type function`이 합성한 레코드 타입에 없어서 타입
에러가 난다(그게 방어선이라는 게 사용자 확정). 그래서 "런타임에 이름이
정해지는" 정당한 용도를 위해 **타입을 호출자가 직접 주는 명시적 창구**를
둔다 — 사용자 판정: *"동적히는 여전히 그냥 Store.Name 하면 얻어는 짐.
타입 애러가 난다는 점인데, 이는 GetDynamic<T>(name): Source<T>
제공하는게 최선으로 보임."* 이름이 명시적이라 "여기서 타입 보장을
포기했다"가 호출부에 드러나는 것도 문자열 커링보다 나은 점.
- **⚠️ [구현 주의, 2026-08-18 감사에서 발견] 콜론 메소드는 Store의
lazy `__index`와 정면으로 부딪힌다.** 위 "Store = Source들의 이름 붙은
모음" 절이 확정한 대로 **없는 키를 인덱싱하면 그 자리에서 `Source`
만들어 저장**하므로, 아무 장치 없이 `store:GetDynamic("x")`를 부르면
`store.GetDynamic`**`"GetDynamic"`이라는 이름의 새 `Source`
만들어 반환**하고 그걸 함수로 호출해 런타임 에러가 난다. 따라서
**`__index`가 고정 메소드 테이블을 먼저 확인하고, 없을 때만 lazy
`Source` 생성으로 폴백**해야 하며, 그 결과 **`GetDynamic`은 Store의
예약 키 이름이 된다**(그 이름의 Source는 dot-access로 못 만듦).
`Modifier``Apply`/`Peek`/`Overridden`을 같은 이유로 예약하는 것과
정확히 같은 구조(`base/modifier-plan.md`의 "구현 시 주의") — 다만
Store의 키 이름은 **사용자 도메인 데이터 이름**이라 Modifier(스타일
프로퍼티 이름)보다 충돌 확률이 높다는 게 차이.
- **대안(미결, 사용자 판단 필요)**: 예약 키를 하나도 만들고 싶지 않으면
**탑레벨 함수**(`getDynamic(store, name)`)로 두면 된다 — `isState`/
`bindLifetime`처럼 "특정 프리미티브에 안 묶인 범용 유틸은 소문자
탑레벨"이라는 기존 네이밍 규칙(`base/architecture.md`의 "코드 스타일 —
네이밍 케이싱")에도 오히려 더 맞는다. 사용자가 지정한 표기는
`GetDynamic<T>(name)`이므로 **일단 콜론 메소드 + 예약 키로 적어두되,
M2/M4 구현 전에 어느 쪽인지 확인할 것**(`question.md` 최우선 절).
위 절), **`store.key`(dot-access)를 1급 경로로 확정**. Store 타입을
`{key: Source<number>, other: Source<string>}`류 **평범한 레코드 타입**으로
지으면 일반 구조적 필드 타이핑으로 자동 해결되고, 문자열 리터럴 narrowing
문제 자체가 안 생긴다.
- **⭐ [2026-08-25] 동적 키는 `store:Of<<T>>(name): Source<T>` 하나다** —
옛 이름 `GetDynamic`을 **흡수**했다(표면 둘을 유지할 이유가 없다).
선언되지 않은 이름은 레코드 타입에 없어서 dot-access는 타입 에러가
난다(그게 방어선이라는 게 사용자 확정). 그래서 "런타임에 이름이 정해지는"
정당한 용도를 위해 **타입을 호출자가 직접 주는 명시적 창구**를 둔다 —
사용자 판정:
*"이는 GetDynamic<T>(name): Source<T> 로 제공하는게 최선으로 보임."*
이름이 명시적이라 "여기서 타입 보장을 포기했다"가 호출부에 드러나는 것도
문자열 커링보다 나은 점.
- **⭐⭐ [2026-08-25 신설] `Of`는 없는 이름이면 그 자리에서 만들어 저장한다 —
여기가 lazy 생성이 남는 유일한 자리다.** 명시적 초기화로 dot-access 쪽
lazy `__index`는 폐기됐지만(위 절), **동적 키 창구는 그 위에 서 있었다**
(`/code-review high` 발견) — `defaults`에 없는 이름을 `Of`가 그냥
조회하면 `nil``Source<U>` 타입으로 돌려주고 호출부가 `:Get()`에서
타입 에러 없이 nil 역참조한다.
```lua
function Store:Of(name) -- 동적 키 전용
local src = shadow[name]
if src == nil then
src = Source() -- == Source(nil)
shadow[name] = src
end
return src
end
```
- **`__index` lazy와 다른 점**: 그건 **암묵**이었고(오타가 조용히 새
Source를 만들었다) 이건 **명시**다 — 이름을 문자열로 넘기고 타입을
`<<T>>`로 직접 주는 자리라 "여기서 타입 보장을 포기했다"가 호출부에
드러난다. 그게 애초에 이 창구를 둔 이유다.
- **`store:Names()`는 그대로 성립한다** — 선언 키(`defaults`)에 `Of`
만든 동적 키가 더해진 것이 그 시점의 실제 키 집합이고, `Names()`
돌려주는 것도 그것이다.
- **오타 방어는 여전히 타입이 한다** — dot-access는 레코드 타입에 없는
이름을 거부한다. `Of`는 그 방어를 **의도적으로** 우회하는 창구다.
- **⭐ [2026-08-25 실측] `<<T>>`는 값 호출부에서 동작한다.**
7라운드 `H-73`이 *"Luau엔 호출부 명시 타입 인자 문법이 없다"*고
단정했으나 **틀렸다** — Luau의 **generic type instantiation**
(`luau.org/types/generics/#generic-type-instantiation`)이 값 호출부에서도,
콜론 메소드에서도 `T`를 실제로 묶는다. `luau-analyze` 실측:
```lua
local ok: Source<number> = store:Of<<number>>("x") -- 진단 없음
local bad: Source<string> = store:Of<<number>>("y") -- 정확히 걸림
local none: Source<number> = store:Of("z") -- Source<unknown>
```
원문은 **인스턴스화를 생략한 호출만** 돌려보고 단정했다. 따라서
`base/quad-types-plan.md`의 이중 꺾쇠 관례는 타입 자리 전용이 아니다.
- **⭐ [2026-08-25] 예약 키는 `Of`/`Names` 둘뿐이고, 충돌은 조용히 죽는다.**
실측: 사용자 키가 예약 이름과 겹치면 교집합이 뭉개져 **그 필드의 타입
검사가 통째로 꺼진다**(음성 대조군이 진단 0건으로 통과했다). 시끄럽게
막히는 게 아니라 그냥 지나간다.
- **그래서 `T`를 검증만 하고 그대로 통과시키는 작은 `type function`
둔다.** 겹치면 사용 지점에
`TypeError: quad.Store: "Of" is a reserved key`가 뜬다.
**`error()`는 못 쓴다** — `type function` 자체가 실패한 걸로 판정돼
버려진다. `print(...)` + `return types.never` 조합만 된다
(`luau-test/rewrite-required/23-...`가 기록해둔 사실이고
`type-version-check`가 이미 쓰는 패턴).
- **이건 아래 §0 원칙의 허용 범위 안이다** — 타입 함수를 **진단을 띄우는
데만** 쓰고 접근 타입을 합성하는 데는 안 쓴다
(`base/typing-limits.md`).
- 이 패턴은 Store에만 국한되지 않고 **인스턴스 생성까지 관통하는 프로젝트
전역 관습으로 확정**됨 — 단 이벤트는 이후 4차 라운드에서 이 관습의
**유일한 예외**로 빠졌음(PA님 방식인 문자열 키+런타임 리플렉션으로 전환).
`base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학"
절이 최신 확정 내용.
### `store.key` 레코드 필드 타이핑 — Luau 타입함수로 해결 확인 (2026-08-12 열일곱 번째 세션, `pre-implementation-audit.md` 1-10 해소)
### `store.key` 레코드 필드 타이핑 — 타입 함수가 필요 없다 (2026-08-25 재확정)
위 절이 "`store.key`를 평범한 레코드 필드 타이핑으로 자동 해결"이라
서술했지만, `Store<T>`가 입력 `T`(예: `{ty: string}`)를 받아
`{ty: Source<string>}`류 결과 타입을 실제로 어떻게 합성하는지는 미검증으로
남아있었음. **Luau의 `type function`**(컴파일타임에 타입을 인자로 받아 새
타입을 조립하는 기능, https://luau.org/types/type-functions/ ,
https://luau.org/types-library/ — tbox에서도 이미 쓰이는 검증된 패턴)으로
정확히 풀림:
> **⭐⭐ [2026-08-25] 타입 함수로 접근 타입을 짓는 접근은 **두 번** 시도됐고
> **두 번 다 폐기**됐다.** (1) `WrapStore`/`ProcessStoreType`로 결과 타입을
> **합성**하는 안(2026-08-12~2026-08-15) — 7라운드 `H-75`/`H-76`이 두
> 한계를 실측해 폐기. (2) `index<>`/`keyof<>` + 팬텀 필드로 짓는 안
> (2026-08-25 오전) — 같은 날 철회
> (`archive/store-value-field-redesign-withdrawn.md`).
```luau
type function WrapStore(ty: type): type
-- Source<T> 형태를 그대로 조립(:Get/:Set/:Compute/:With 등)
local result = types.newtable()
result:setproperty(types.singleton("Get"), types.newfunction(...))
return result
end
**지금은 타입 함수를 안 쓴다.** `Store<T>``T`가 이미
`{hp: Source<number>, ...}`이므로 `store.hp`는 **평범한 레코드 필드
접근**이고, Luau가 그냥 해준다.
type function ProcessStoreType(ty: type): type
local props = ty:properties() :: { [type]: { read: type?, write: type? } }
local result = types.newtable()
for i, v in props do
-- i는 프로퍼티 이름을 담은 singleton 타입, i:value()로 실제 문자열
result:setproperty(i, WrapStore(v))
end
return result
end
```lua
type Store<T> = T & {
Of: <U>(self: any, name: string) -> Source<U>, -- 동적 키 전용
Names: (self: any) -> { string },
}
```
`ProcessStoreType<{ty: string}>``{ty: Source<string>}`가 나옴 — 결과는
선언 시점에 이름 붙은 `Source<string>` 그 자체가 아니라 구조를 그대로 풀어낸
(flatten) 익명 타입이지만, **Luau는 이름이 아니라 "만족하는가"로 구조적
일치를 검사**하므로 문제없이 `Source<string>` 자리에 대입 가능 — 오히려 이
방식과 정확히 맞는 조합. 이걸로 `store.key`가 실제로 타입 명시 가능함이
확인돼 M0/M2 어느 시점에 검증해도 기술적으로 막힐 위험은 없음 —
`ROADMAP.md`의 M0/M2 배치를 강제로 바꿀 필요는 없어짐, 설계 레벨의 검증
난이도 문제였던 것만 해소. **[2026-08-15] 이 `type function` 접근 자체의
실측도 완료** — 스파이크(`luau-test/done/16-type-store-key-
typefunction.luau`)는 원래 `types.newfunction` 시그니처 불일치로 깨져
있었으나 원인이 설계 문제가 아니라 API 버전 드리프트였음이 드러나 수정
후 통과(음성 대조군 4건 포함), `base/typing-limits.md` §5로 승격.
상세는 `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절.
**`luau-analyze` 실측**(양성 + 음성 대조군):
| 검사 | 결과 |
|---|---|
| `store.hp` | `Source<number>` ✅ (`Source<string>` 대조군 걸림) |
| `store.hp:Get()` / `:Set(5)` | ✅ (틀린 타입 대조군 둘 다 걸림) |
| `store.nope` | **거부** ✅ |
| `store:Of<<boolean>>("dyn")` | `Source<boolean>` ✅ (대조군 걸림) |
| 콜백 파라미터 추론(`store.hp:Compute(function(s) ... end)`) | ✅ `s``StateData<number>`로 잡힘(`s:NoSuchField()`가 정확히 걸림) |
- **⚠️ `Compute`/`Apply`의 반환 타입은 여전히 명시 주석이 필요하다** —
이건 Store와 무관한 `base/typing-limits.md` §1의 **문제 B**이고,
`store.hp`든 독립 `Source`**똑같이** 조용히 안전성을 잃는다(실측:
틀린 주석 `local x: State<string> = src:Compute(...)`이 양쪽 다 안 걸림).
`audit/type-recursion-issue/REPORT.md` 3-1절이 확정한 대로 **명시 주석
이후 다운스트림 전체는 정상 체크**되고 구멍은 그 한 줄뿐이다.
살아나는 건 **콜백 파라미터** 쪽이다 — 그리고 철회된 재설계의
`Self` 제네릭을 거치면 **그것마저 깨졌다**(`s`가 `unknown`으로 떨어짐).
- **`__call` 경로는 죽었다** — 타입 레벨 `__call``self`를 못 받고,
`typeof(f<<T>>)`로 타입 인자를 넘기는 것도 실패한다(실측). 같은 사실이
`base/source-state-plan.md``:Apply`에도 적용된다(7라운드 `H-94`).
실측 전량은 `audit/type-store-index-keyof/`가 소스.
## Store가 Store를 저장 가능한가

View file

@ -45,6 +45,28 @@
사례 — 코드 생성 없이 타입 선언 두 개로 끝나고, 나중에 Luau가
고쳐져도 손해가 없음).
### ⭐⭐ [2026-08-25 신설, 사용자] 타입 함수는 **진단을 띄우는 데까지만** 쓴다
**사용자 확정**: *"어쩌면 타입함수는 타입이 못 잡는 문제를 에러로 띄우기
위한 정도 이상으로 가지 않는게 이로울 수도 있습니다."*
- **허용**: 타입이 못 잡는 오용을 **컴파일 타임 에러로 만드는** 용도.
`type-version-check``CheckVersion`(버전 불일치를 사람이 읽을
메시지로), Store의 `CheckReserved`(예약 키 충돌)가 그 사례다. 둘 다
**`T`를 검증만 하고 그대로 통과**시키고 결과 타입을 만들지 않는다.
`error()`가 아니라 `print(...)` + `return types.never`를 쓴다.
- **안 함**: **접근 타입/결과 타입을 합성**하는 용도.
- **왜**: 그 선을 넘으면 §6이 실측한 함정(합성을 거친 값은 이후 제네릭
self 체이닝이 조용히 깨짐)과 §5가 폐기한 한계(바깥 별칭 참조 불가,
메소드 self 파라미터 불변)를 그대로 떠안는다.
- **⚠️ `index<>`/`keyof<>`도 타입 함수다** — Luau가 predefine해둔 것일
뿐 성질은 같다(**사용자 지적**: *"구현을 뜯어보면, 루아우에서
프리디파이닝한 타입함수임. 타입함수가 가지는 고질적 문제를 그대로
가져요."*). 실제로 2026-08-25에 `WrapStore`를 버리고 그쪽으로 갈아탔다가
**같은 날 철회**했다 — 그 조합의 `Self` 제네릭을 거치면 **콜백 파라미터
추론이 깨진다**(평범한 레코드 필드에선 정상, 실측 대조).
경위는 `archive/store-value-field-redesign-withdrawn.md`.
---
## 1. ⭐ 재귀 제네릭이 다른 타입 인자로 자기를 반환하면 타입 안전성이 조용히 사라짐
@ -242,7 +264,7 @@ self 핸들 자체를 받음)에서 **콜백 반환 타입이 self의 원래 T
| `state:With(...)` | 인라인 + 쪼개기 | 쪼개기로 해결(이형 dep 포함) | ❌ 명시 바인딩 필요 |
| `state:Apply(factory)` | 인라인 | factory 파라미터 주석 필요 | ❌ 명시 바인딩 필요 |
| `Effect(fn, ...deps)` | — | 해당 없음(자유 함수) | 해당 없음(반환이 재귀 타입 아님) |
| `state:Observer(fn)` | — | 해당 없음(로컬 제네릭 없음) | 해당 없음(`EffectHandle` 반환) |
| `state:Observer(fn)` | — | 해당 없음(로컬 제네릭 없음) | 해당 없음(`Observer` 반환) |
| `tween:Mapped(fn)` | 인라인 제네릭 메소드 | — | ❌ **조용히 통과**(아래 `H-24`) |
| `tween:Mapped(fn)` | `typeof(named fn)`(③) | 콜백 파라미터 명시 주석 필요 | ✅ 안전 |
@ -350,14 +372,34 @@ RFC가 순수 내부 변경이고 우리 선언이 이미 그 대상 모양이
---
## 5. `store.key` 레코드 필드 타이핑(`type function`) — ✅ 검증 완료
## 5. `store.key` 레코드 필드 타이핑 — ⛔ 이 접근은 폐기됨 (2026-08-25)
**[2026-08-15 확정, 근거: `luau-test/done/16-type-store-key-typefunction.luau`,
`audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절]**
> **⭐⭐ [2026-08-25 폐기] `WrapStore`/`ProcessStoreType`로 결과 타입을
> **합성**하는 접근 자체가 사라졌습니다.** 지금 Store는 **타입 함수를 안
> 쓰고** 타입 인자에 `Source<T>`를 직접 써서 **평범한 레코드**로
> 타이핑합니다(같은 날 `index<>`/`keyof<>` + 팬텀 필드로 가는 안을
> 넣었다가 위 §0 원칙에 따라 철회했습니다 —
> `archive/store-value-field-redesign-withdrawn.md`) —
> `base/store-plan.md`의 "`store.key` 레코드 필드 타이핑" 절이 소스,
> 뒤집힌 원문은 `archive/store-value-field-redesign-withdrawn.md`.
> 아래는 그 접근이 살아 있던 동안의 실측 기록이고, **`type function`
> 자체의 성질**(API 시그니처, 재귀 호출 한계)은 여전히 유효합니다.
>
> **왜 폐기됐나**(7라운드 `H-75`/`H-76` 실측): (1) 합성 결과가 **평평하면**
> `store.key:Compute(무주석 콜백)`이 깨진다 — 아래 1번의 ②쪼개기를
> `type function` **안에서도** 해야 한다는 뜻인데 그게 문서 어디에도
> 없었다. (2) `type function`은 **바깥 타입 별칭을 참조하지 못해**
> `Source<T>` 전 표면을 구조적으로 중복 작성해야 하고, **메소드 self
> 파라미터가 불변**이라 필드 하나만 어긋나도 `store.key``State<T>`
> 파라미터 자리에 안 들어간다. 스파이크 `16`은 그 대입을 한 번도
> 해보지 않았다.
**[2026-08-15 확정, 근거: `luau-test/rewrite-required/16-type-store-key-typefunction.luau`,
`audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절 — 폐기 전 기록]**
`Store<T>``{[K]: Source<V>}` 합성을 Luau `type function`으로 하는
설계(`pre-implementation-audit.md` 1-10)는 **설계와 실측 둘 다
확정**입니다. 원래 스파이크가 깨졌던 이유는 설계 문제가 아니라
설계(`pre-implementation-audit.md` 1-10)는 그 시점 **설계와 실측 둘 다
확정**이었습니다. 원래 스파이크가 깨졌던 이유는 설계 문제가 아니라
**`types.newfunction`의 API 버전 드리프트**였습니다 — 시그니처가
`(parameters: {head: {type}?, tail: type?}, returns: {head: {type}?,
tail: type?}?, generics: {type}?): type`로 parameters/returns 둘 다
@ -466,7 +508,38 @@ local extended = checked:AddPlugin(somePlugin) -- 안 깨짐 — checked의 T
`PostRef`도 같이 커버할 것.
- **Modifier의 제네릭 `__index` + `table.clone` 체이닝**`luau-test/done/17`.
- **콜백 파라미터/본문의 타입 체크**(1번의 쪼개기 적용 시) — 진짜
살아있음.
살아있음. **⚠️ [2026-08-25 경계 명시, 7라운드 `H-96`] 단 trailing deps가
붙으면 dep 파라미터엔 주석이 필요하다** — deps가 0개면 콜백 파라미터가
무주석으로 추론되지만, `:Compute(fn, a, b)`처럼 trailing deps가 붙는
순간 그 dep 파라미터들은 해소되지 않아 `Consider annotating the return`
뜬다. ②쪼개기가 푸는 범위 **밖**이다.
- **명시적 제네릭 인스턴스화 `f<<T>>(...)`가 값 호출부에서 동작**한다
— 콜론 메소드에서도 된다(**[2026-08-25 실측]**, 7라운드 `H-73`
"문법이 없다"고 단정했던 것을 뒤집음). 자세한 건
`base/store-plan.md`의 "타입 추론 문제" 절.
- **⭐ [2026-08-25 실측] 싱글톤 보존 — 타입 후보 중에 싱글톤이 있으면
`string`으로 안 뭉개진다.** `index<>`/`keyof<>` 기반 키 타이핑 전체가
이 성질에 기대고 있어서 따로 확인했다:
| 선언 | `f("bbb")``T` | 진단 |
|---|---|---|
| `f<T>(input: T): T` | `string` ❌ 뭉개짐 | 없음 |
| `f<T>(input: T & string): T` | `unknown` ❌ | 없음 |
| `f<T>(input: T & ""): T` | `"bbb"` ✅ | **에러**(교집합이 빔) |
| **`f<T>(input: T \| "" \| string): T`** | `"bbb"` ✅ | **없음** ✅ |
| **`f<T>(input: T & ("A" \| "B")): T`** | `"B"` ✅ | 범위 밖이면 정확히 걸림 ✅ |
**[2026-08-25] 다만 quad는 이 성질에 기대는 설계를 채택하지 않았다** —
`K & keyof<...>` 기반 Store 타이핑은 같은 날 철회됐다(위 §0). 아래는
Luau 자체의 성질로만 기록해둔다. 사용자 관찰:
*"K& 를 걸고 유니온 스트링을 걸면, K 가 싱글톤으로써, string 으로
뭉개지지 않고 전해진다."* 대조 전량은
`audit/type-store-index-keyof/REPORT.md`.
- **⛔ 타입 레벨 `__call`은 죽은 경로다**(**[2026-08-25 실측]**) — `self`
못 받고(`Argument count mismatch`), `typeof(f<<T>>)`로 타입 인자를
실어 나르는 것도 실패한다. 그래서 콜러블 팩토리는 `__call`이 아니라
**지정된 필드**로 자기를 노출한다(`base/source-state-plan.md`의
"`state:Apply(factory)`" 절).
---

View file

@ -88,12 +88,12 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
| `13-type-ref-preref-subtype.luau` (타입체크 전용) | **[2026-08-19 재작성]** `PreRef<T>`/`PostRef<T>`가 `Ref<T>`를 구조적으로 만족하는지 — 원래 이 파일에 있던 런타임(B) 부분은 A의 더미 스텁이 실행을 막아 도달 불가였던 문제라 `22`로 분리, PostRef까지 확장 | `brand-plan.md``Brand` 절(2026-08-09 열한 번째 세션 재정정), `ref-plan.md`의 "`PostRef`" 절 |
| `14-type-nilable-default-overload.luau` (타입체크 전용) | `Source(default)`/`Ref(default)`의 `default` 생략이 `T`가 nilable일 때만 안전하다는 캐비엇을, 함수 오버로드(교차 타입)로 실제로 타입 레벨에서 막을 수 있는지 | `source-state-plan.md` "State는 쓰기 대상이 아님" 절의 `default` 생략 캐비엇 |
| `15-type-compute-trailing-deps-typepack.luau` (타입체크 전용) | `:Compute(fn, ...)`의 trailing deps를 `fn`에 위치 인자(lazy State 핸들)로도 노출하는 확장, 최종 시그니처 `fn(self, previous?, ...deps)` — 이형(heterogeneous) 다중 deps를 제네릭 타입 팩(`U...`)으로 표현 가능한지, `previous?`가 팩 앞(정정된 순서)에서만 통과하고 팩 뒤(옛 순서)에서는 막히는지 | `source-state-plan.md` "trailing deps를 fn에 lazy positional 인자로도 노출" 절(2026-08-11 후속 세션, 순서는 같은 날 세 번째 세션에 정정) |
| `16-type-store-key-typefunction.luau` (타입체크 전용) | `Store<T>``T`의 각 필드를 `Source`로 감싼 타입을 Luau `type function`(`types.newtable`/`:setproperty`/`ty:properties()`)으로 실제 합성 가능한지, 결과가 구조적으로 `Source<T>` 필드를 만족하는지. **[2026-08-15] 통과 → `done/`** — 원인은 설계가 아니라 `types.newfunction` API 버전 드리프트였음, `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절 | `typing-limits.md` "`store.key` 레코드 필드 타이핑" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-10 |
| `16-type-store-key-typefunction.luau` (타입체크 전용) | `Store<T>``T`의 각 필드를 `Source`로 감싼 타입을 Luau `type function`(`types.newtable`/`:setproperty`/`ty:properties()`)으로 실제 합성 가능한지, 결과가 구조적으로 `Source<T>` 필드를 만족하는지. **[2026-08-15] 통과 → `done/`** — 원인은 설계가 아니라 `types.newfunction` API 버전 드리프트였음, `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절 | `typing-limits.md` "`store.key` 레코드 필드 타이핑" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-10 **⛔ [2026-08-25 이동, `rewrite-required/`] 검증 대상이 폐기됐다** — `WrapStore`/`ProcessStoreType`로 결과 타입을 **합성**하는 접근 자체가 Store 재설계로 사라졌다(`base/store-plan.md`, 역전 원문은 `archive/store-value-field-redesign-withdrawn.md`). 재작성 지침은 `STATUS.md`가 소스 — **타입 함수 없는 평범한 레코드** 모양을 검증하고 음성 대조군에 **예약 키 충돌**(`CheckReserved`)과 없는 키 접근을 넣을 것 |
| `17-modifier-index-tableclone-chaining.luau` | Modifier의 제네릭 `__index`+`table.clone` 체이닝 — 임의 필드 이름에 대해 즉석 setter가 만들어지는지, `table.clone`이 메타테이블을 참조로 공유해 여러 단계 clone에서도 체이닝이 안 끊기는지, 원본이 mutate 안 되는지, 형제 분기끼리 오염 안 되는지 | `modifier-plan.md` "런타임은 클래스별 코드 없이 base에 딱 하나만 있으면 됨" 절 + "`table.clone`의 정확한 동작 — 확인됨" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-11 |
| `18-relate-mutual-cycle-gc.luau` | **[2026-08-13 신규]** 서로 다른 두 `Relate`가 서로의 키를 상대방의 강한 값으로 제공하는 상호 순환은 Luau에 ephemeron이 없어 GC가 못 푼다는 주장(지금까지 공식 문서 인용으로만 뒷받침됨) — 음성 대조군(순환 재현)과 양성 대조군(한쪽을 weak-value로 낮추면 풀리는지) 둘 다 실측 | `relate-plan.md` "위험한 패턴" 절(2026-08-12 열세/열네 번째 세션), `slot-plan.md``kSlotMap`/`slotOwner`/`elementOwner` 실사례 |
| `19-ownership-refcount-relate-patterns.luau` | **[2026-08-13 신규, 같은 날 B/C 전면 재작성 — 지금은 현행 설계 기준]** 세 소유권/참조카운트 알고리즘 검증. **A**: Tag `tagNameMap` 참조 카운트(여러 위치가 같은 이름을 겹쳐 가져도 마지막 홀더가 빠질 때만 실제 `RemoveTag`. 옛 `kTagMap`은 클로저 캡처로 대체돼 삭제됨). **B**: Attribute 이름 소유권 — 공개 `AttributeKey(name)` 캐시 + `Dispatch.process`의 인덱스 1 **점유 체크**가 충돌을 잡는지(옛 `rawNew`+`owners` 수동 레지스트리는 폐기). **C**: Slot 소유권 — nested 엄격 `claimOwner`(같은 owner 재클레임도 error) vs top-level `claimOwnerAt(inst,k)`(정확히 같은 자리 재발행만 no-op). **셋 다 음성 대조군 포함** — 옛 로직이 `Slot{a,a}`/`Frame{slot,slot}`을 조용히 통과시키는 걸 재현. **[2026-08-13 열네 번째 세션] 0-Z가 확정되며 B 섹션이 낡음 → `rewrite-required/`** — 이제 "그룹 전용 키 + `AttributeKeyHandler`의 이름 claim"을 검증해야 함(A/C는 그대로 유효) | `tag-plan.md` "메커니즘", `attribute-plan.md` "이름 소유권", `slot-plan.md` "요소 소유권" |
| `20-slot-splice-index-arithmetic.luau` | **[2026-08-13 신규]** `Slot:Splice(index, removeCount, ...newElements)`의 shift+recompute 1회 계산이, `Extract`/`Add` 반복으로 재현한 참조 구현과 항상 같은 결과를 내는지 — 제거/삽입 길이가 다를 때(delta 양수/음수) 뒤 요소가 밀리는 방향과 양을 헷갈리는 off-by-one 위험(이 프로젝트가 `Dispatch.recompute`에서 실제로 냈던 것과 같은 클래스의 버그)을 경계값 케이스로 검증 | `slot-plan.md` "확정" CRUD 표 + "`Splice` 신설" 절(2026-08-12 열다섯 번째 세션), `dispatch-core-plan.md``recompute` off-by-one 수정 사례(2026-08-11 여섯 번째 세션) |
| `21-type-store-undeclared-key-rejected.luau` (타입체크 전용) | **[2026-08-19 신규]** `Store<{field: T}>`로 선언 안 된 이름에 dot-access하면 `type function`이 합성한 결과 타입(`ProcessStoreType`, `16`과 동일)에 그 프로퍼티가 없어 타입 시간에 거부되는지 — `store-plan.md`가 "아마 그럴 것"으로만 적어뒀던 걸 M0에서 실측. 통과: 미선언 키 접근 2건이 정확히 `TypeError`로 걸림 | `store-plan.md` "Store = Source들의 이름 붙은 모음" 절의 "[확인 요구, 2026-08-18 구현 전 QA]" 항목, `todos.md` 00번 |
| `21-type-store-undeclared-key-rejected.luau` (타입체크 전용) | **[2026-08-19 신규]** `Store<{field: T}>`로 선언 안 된 이름에 dot-access하면 `type function`이 합성한 결과 타입(`ProcessStoreType`, `16`과 동일)에 그 프로퍼티가 없어 타입 시간에 거부되는지 — `store-plan.md`가 "아마 그럴 것"으로만 적어뒀던 걸 M0에서 실측. 통과: 미선언 키 접근 2건이 정확히 `TypeError`로 걸림 | `store-plan.md` "Store = Source들의 이름 붙은 모음" 절의 "[확인 요구, 2026-08-18 구현 전 QA]" 항목, `todos.md` 00번 **⚠️ [2026-08-25 이동, `rewrite-required/`]** `16``ProcessStoreType`을 재사용하므로 같이 낡았다. **검증 대상(미선언 키는 타입 에러)은 그대로 유효**하고 새 모양에서도 성립함이 확인됐다(`store.nope` 거부) — 새 `Store<T>` 선언으로 바꿔 쓰기만 하면 된다 |
| `22-runtime-ref-preref-postref-brand.luau` | **[2026-08-19 신규]** 구 `13`의 런타임(B) 절반을 분리한 것 — `isPreRef`/`isPostRef`가 같은 층위의 배타적 형제(둘 다 `isRef``true`, 서로에겐 `false`)인지, Leaf 핸들러 흉내(`isRef(v) and not isPreRef(v) and not isPostRef(v)`)가 Ref/PreRef/PostRef 셋을 정확히 갈라내는지. **[2026-08-21] `rewrite-required/`로 이동** — 파일이 직접 구현해 쓰는 `Brand.set`/`Brand.get`이 인스턴스 브랜드 재작성으로 역전된 옛 API가 됐다(검증 대상 자체는 그대로 유효, 상태와 재작성 지침은 `STATUS.md`가 소스) | `ref-plan.md`의 "`PostRef`" 절, `brand-plan.md`의 "⭐ 구현 — 인스턴스 브랜드" 절 |
| `23-type-quadtypes-checkversion-addplugin.luau` (타입체크 전용) | **[2026-08-19 신규, 같은 날 후속으로 재작성]** 실제 `quad-types`/`quad-base`/`type-version-check`를 `require`해서 `CheckedQuad<T, Pattern>`(글롭/캐럿 버전 패턴 체크, `type-version-check` 위에 얹힘)이 `AddPlugin<Self,P>` 체이닝과 맞물려 동작하는지 — 양성(버전 일치 + 2단 체이닝 + 이전 확장 필드 보존), 음성(버전 불일치 → 강제 참조 시점에 정확히 `TypeError`). `type function`을 거친 값은 패스스루라도 이후 제네릭 self 체이닝이 깨진다는 걸 이 스파이크가 재작성 과정에서 직접 발견. 재작성 과정에서 `export type function`(cross-package 필수)과 2개 이상 명시 제네릭 인스턴스화의 이중 꺾쇠(`Foo<<A,B>>`) 요구도 추가로 실측 확인 | `quad-types-plan.md`, `typing-limits.md` §6 |

View file

@ -46,9 +46,17 @@
| 폴더 | 뜻 | 개수 | 누가 처리 |
|---|---|---|---|
| `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 |
| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 7 | 에이전트 |
| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 9 | 에이전트 |
| `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 0(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 |
| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 16 | — |
| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 14 | — |
**⚠️ [2026-08-25 신설] 타입 스파이크는 `./scripts/test.sh`가 하는 리링크를
먼저 거쳐야 한다.** `luau` CLI가 심볼릭 링크를 못 타는데(디렉토리·파일 둘
다) pesde의 워크스페이스 링크가 전부 심볼릭이라, 실제 패키지를 `require`하는
스파이크(`23` 등)는 리링크 없이는 **의도한 진단 대신 링크 실패 진단**이
뜬다 — 게다가 `luau-analyze`는 그걸 **조용히 통과**시키기도 한다(모듈을
`any`로 떨어뜨림). `scripts/relink.sh`를 돌린 뒤 확인할 것. 경위는
7라운드 `H-78`.
**폴더를 옮기는 게 곧 상태 갱신** — 스파이크를 고치거나 돌렸으면 파일을
해당 폴더로 `git mv`하고 아래 표의 줄도 같이 옮길 것. 파일별 "무엇을 왜
@ -95,6 +103,18 @@
통과 상태로 `done/`에 두면 `01`은 구현이 안 하는 두 루프 순회를, `05`
**이제 접히는 중복 통지가 안 접힌다는 것**을 "검증됨"으로 오독하게 된다.
**[2026-08-25] `16``21`이 합류** — Store 재설계로 **검증 대상 자체가
폐기**됐다. `WrapStore`/`ProcessStoreType`로 결과 타입을 **합성**하는
접근이 사라졌다 — 지금은 **타입 함수를 안 쓰고** 타입 인자에 `Source<T>`
직접 써서 평범한 레코드로 짓는다(`base/store-plan.md`). 같은 날
`index<>`/`keyof<>` + 팬텀 필드 안을 넣었다가 `typing-limits.md` §0 원칙에
따라 철회한 경위는 `archive/store-value-field-redesign-withdrawn.md`. 발단은 7라운드
`H-75`/`H-76` 실측이다 — 평평한 합성이면 `store.key:Compute(무주석
콜백)`이 깨지고, `type function`이 바깥 타입 별칭을 참조하지 못해 구조를
통째로 중복 작성해야 하며 메소드 self 파라미터가 불변이라 필드 하나만
어긋나도 대입이 실패한다. **`21`의 검증 대상(미선언 키는 타입 에러)은
그대로 유효**하고 새 모양에서도 성립함이 확인됐다.
**[2026-08-21 후속] `22`도 같은 이유로 합류** — `Brand`가 **인스턴스
브랜드**로 전면 재작성되면서(`base/brand-plan.md`) 이 스파이크가 직접 구현해
쓰는 `Brand.set`/`Brand.get`/`XxxTag`가 **역전된 옛 API**가 됐다
@ -112,6 +132,8 @@
| `19-ownership-refcount-relate-patterns.luau` | A/C ✅ 유효, **B 섹션이 낡음** | B가 검증하던 "공개 `AttributeKey(name)` + 인덱스 1 점유 체크"가 폐기됨 — **그룹 전용 키 + `AttributeKeyHandler`의 이름 claim**으로 재작성하고, 음성 대조군도 "두 그룹이 같은 이름 → 즉시 error", "그룹↔직접 쓰기 → 즉시 error"로 바꿀 것(0-Z 확정 내용). A/C는 손댈 것 없음 |
| `15-type-compute-trailing-deps-typepack.luau` | **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 |
| `22-runtime-ref-preref-postref-brand.luau` | 옛 `Brand` API 기준으로는 ✅ 통과였음 | **[2026-08-21] `Brand`가 인스턴스 브랜드로 재작성됨** — 파일 안의 `Brand.set(x, tag)`/`Brand.get(x)`/`XxxTag` 변수를 `Brand()` + `SomeBrand:register(x)`/`SomeBrand:is(x)`로 바꿔 쓸 것(`base/brand-plan.md`). **검증 대상(`isPreRef`/`isPostRef` 배타 + 둘 다 `isRef`엔 `true`, Leaf 핸들러 흉내)은 그대로**라 assert는 손댈 게 없다. **새로 넣을 것**: 다중 태깅이 실제로 되는지 — 한 값을 두 브랜드에 등록하고 양쪽 `:is`가 다 `true`인지(`Source`가 `SourceBrand`+`EpochBrand`인 자리, `base/state-epoch-plan.md` §2) |
| `16-type-store-key-typefunction.luau` | 옛 접근 기준으로는 ✅ 통과였음 | **[2026-08-25] 검증 대상이 폐기됨** — `WrapStore`/`ProcessStoreType` 합성 자체가 사라졌다. **재작성 지침**: 타입 함수 없는 평범한 레코드 모양(`base/store-plan.md`의 "`store.key` 레코드 필드 타이핑" 절)을 검증하고, 음성 대조군에 **예약 키 충돌**(`CheckReserved`가 `types.never`로 무너뜨리는지)과 **없는 키 접근**을 포함할 것 |
| `21-type-store-undeclared-key-rejected.luau` | 옛 접근 기준으로는 ✅ 통과였음 | `16``ProcessStoreType`을 재사용하므로 같이 낡음. **검증 대상(미선언 키가 타입 에러)은 그대로 유효**하다 — 새 `Store<T>` 선언으로 바꿔 쓰기만 하면 된다(`store:Of("nope")`이 거부되는 것도 같이 넣을 것) |
| `10-roblox-studio-checks.server.luau` (Studio 전용) | 미실행 + **A 섹션이 옛 모델** | A가 옛 2-인자 `canExecute(inst,value)``bindLifetime``.Subscribed` 세팅을 검증 중 — **`bindLifetime`이 gcconn을 `value` 쪽 릴레이션에 복사하는 모델**로 재작성할 것(`base/lifecycle-pattern.md`). **[2026-08-14 열한 번째 세션 재정정, 2026-08-18 방향 정정]** 이중 바인딩 게이트는 `canBound(value)`(`if not canBound(v) then error(...) end` — `canBound` 참 = "지금 묶어도 됨") — `canExecute`는 State emit 전파 게이팅 전용으로 분리됨, 둘 다 비공개 헬퍼 `isBoundAlive`를 공유하는 1-인자 진입점이지만 **서로의 부정**(`base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절). **살릴 것**: "ClassName 신호 미발화 / Destroy 시 `Connected` 즉시 전환" 검증(새 모델에서 더 중요해짐), gcconn/gchold를 **Instance 생성 시점**에 만드는 것으로 바꿀 것(옛 lazy 생성 폐기). B/C 섹션은 손댈 것 없음 |
## ⚪ `not-run/` — 이 환경에서 못 돌림
@ -160,8 +182,6 @@
| `12-type-attribute-generic-key-narrowing` | ❌지만 **설계 영향 없음** — 제네릭 키 narrowing이 안 되는 건 `attribute-plan.md`가 이미 fallback으로 예비해둔 결과(타입 패밀리가 유일하게 믿을 경로). **[2026-08-24 `H-54`] 단 스파이크 자신의 주석이 실제 결과와 어긋난다** — *"이건 당연히 통과해야 함"*이라 적어둔 동질 대조군(line 41-43)이 실제로는 에러를 낸다(`AttributeKey<T>(name)`이 문맥에서 `T`를 못 추론해 `unknown`으로 남음). 오히려 "왜 진짜 테스트 대상이 조용히 통과하는지(= narrowing이 아예 안 일어남)"를 설명해주는 정합적 결과라 **이 총론은 그대로 유효**하고, 근거 라인만 다르다 — 재작성 시 참고 |
| `13-type-ref-preref-subtype` | **[2026-08-19 재작성]** ✅ 통과 — `PreRef<T>`/`PostRef<T>` 둘 다 `Ref<T>`를 구조적으로 만족(음성 대조군도 정확히 에러). 런타임 B섹션은 `22`로 분리(A의 더미 스텁이 B 실행을 막던 문제 해결) |
| `14-type-nilable-default-overload` | ⚠️ 부분 — 의도한 오용은 막지만 정상 nilable 사용례까지 막아 현 스케치로는 채택 불가. **설계 결정은 아직 필요 없음**(대안이 이미 UB 경고로 존재)이라 `review-required`가 아님 |
| `16-type-store-key-typefunction` | **[2026-08-15]** ✅ 통과 — 원인은 설계 문제가 아니라 `types.newfunction` API 버전 드리프트(배열이 아니라 `{head=...}` 레코드). `ProcessStoreType<Input>`이 정확히 `{ty: Source<string>, count: Source<number>}` 구조를 만족, 음성 대조군 4건(틀린 Get/Set 타입 2건, 존재하지 않는 메소드) 전부 정확히 에러. 근거: `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절 |
| `21-type-store-undeclared-key-rejected` | **[2026-08-19 신규]** ✅ 통과 — `16``ProcessStoreType`을 재사용해 미선언 키 접근 2건(읽기, 메소드 체이닝)이 정확히 `TypeError`로 거부됨을 확인, 양성 경로(선언된 키 3개) 클린. `store-plan.md`가 "아마 그럴 것"으로만 적어뒀던 걸 M0에서 실측 확정 |
| `23-type-quadtypes-checkversion-addplugin` | **[2026-08-19 신규, 같은 날 후속으로 재작성]** ✅ 통과 — 실제 `quad-types`/`quad-base`/`type-version-check`로 `CheckedQuad<T, Pattern>`+`AddPlugin<Self,P>` 통합 검증. 재작성 과정에서 `type function`을 거친 값은 패스스루라도 이후 제네릭 self 체이닝이 조용히 깨진다는 새 Luau 함정 발견(`typing-limits.md` §6으로 승격), `export type function`/이중 꺾쇠 제네릭 인스턴스화 요구도 같이 실측 — 최종 설계(별도 가상 필드로 격리)는 양성/음성 경로 모두 클린 |
### 특별히 중요한 통과 3건
@ -207,5 +227,6 @@ inst 5개만 살린 상태 → 살아남은 payload 5 / 엔트리 5 (기대치
| 검증할 것 | 왜 | 출처 |
|---|---|---|
| ~~`table.insert`가 배열 중간의 구멍을 재사용하는가~~ **[2026-08-24 폐기]** | **전제 자체가 없어졌다** — 6라운드 `H-7``Ref.Callbacks`가 배열에서 `{[callback\|thread] = true}` **해시맵 셋**으로 바뀌었고, 해시맵엔 border 개념도 구멍도 없다(`base/ref-plan.md`). 이 스파이크는 만들지 말 것 | QA 4라운드 `R-11`(폐기), 6라운드 `H-7` |
| 중간 State가 상류 strong / 하류 weak 불변식으로 실제로 살아남는가 | `State → State → State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도 없어 전파가 조용히 끊길 수 있음. **M2 착수 전 필요** | `base/source-state-plan.md`의 "미해결 — 중간 State가 살아남는가" 절, `question.md` 최우선 절 |
| 중간 State가 `_hold`(하류 → 상류 강함) 불변식으로 실제로 살아남는가 | **[2026-08-25] 설계는 확정됐다** — 각 파생 노드가 자기 상류를 `_hold`로 강하게 든다(사용자 확정). 남은 건 실측뿐이고 **M2 착수 게이트는 아니다**(`question.md` 최우선 절에서 내려감). 음성 대조군으로 "`_hold` 없이 짜면 중간 노드가 수거돼 전파가 끊긴다"까지 볼 것 | `base/source-state-plan.md`의 "해소됨 — 중간 State는 `_hold`로 살아남는다" 절 |
| `Relate` 값/내부 키가 바깥 키를 되참조하면 새는가 | **[2026-08-25 신설, 7라운드 `H-71`/`H-77`]** `done/07`**안전한 모양만** 봤는데 여러 문서가 그걸 "GC-native 아키텍처의 핵심 전제 검증"으로 인용한다. 실제로는 (a) `SetStrong`**값**이 되참조하면 100% 새고, (b) **내부 키**가 되참조하면 `SetStrong`/`SetWeak` **둘 다** 샌다. `07`에 음성 대조군으로 추가할 것 | `base/relate-plan.md`의 "위험한 패턴" 절 슬롯 표 |
| `Visible = false`인 GuiObject의 `AbsoluteSize`/`AbsolutePosition`이 갱신되는가 | `quad-roblox-fastscroll` 설계의 선행 실측. **Studio 필요** — 만들면 `not-run/`행 | `research/fastscroll-plan.md` |

View file

@ -17,14 +17,21 @@ M3의 번호·순서가 맞바뀌었다** — 열려 있던 마일스톤 순서
교체로 닫힌 결과다(경위는 `archive/question-resolved.md`의 "마일스톤 경계"
절, 새 구성은 `ROADMAP.md`의 M2 배너). **2026-08-24 이전에 쓰인
`session/`·`archive/`·`qa-request/`의 `M2`/`M3`는 옛 의미**(M2=디스패치,
M3=반응형)다. 그 교체의 부작용으로 **M2 착수 전에 답이 필요한 항목이
`question.md` 최우선 절로 올라왔다**(무엇이 몇 개인지는 그 절이 소스 —
여기서 세지 않는다. 설계 게이트가 아니라 실측·표면 선택이라, 설계 게이트가
없다는 아래와 `todos.md`의 서술과 모순되지 않음). 저장소 루트에
M3=반응형)다. 그 교체의 부작용으로 한때 `question.md` 최우선 절에 항목
둘이 올라와 있었으나, **⭐ [2026-08-25] 둘 다 닫혀 그 절은 지금 비어 있다**
(중간 State GC는 `_hold` 불변식으로, `store:GetDynamic` 위치는 콜론 유지 +
`CheckReserved` 타입 함수로 — 7라운드 손 트레이싱 후속, 결정 전량의 소스는
`qa-request/pre-implementation-handtrace-round7-followup.md`). **M2 착수를
막는 항목은 설계든 실측이든 없다.** 저장소 루트에
`quad-base/src/`(`New()`/`RunInit`/`AddPlugin`/`Relate`/`Debug`)/
`quad-types/src/`/`type-version-check/src/`가 실제로 존재(`quad-roblox/src`는
아직 빈 폴더 — M5에서 채워짐), 자세한 진행 상황은 루트 `ROADMAP.md`
소스. 핵심 아키텍처(Store 책임 분리, `process`/`retract`
소스. **⚠️ [2026-08-25] 테스트는 `./scripts/test.sh`로 돌릴 것** —
`luau` CLI가 **심볼릭 링크를 못 타는데**(디렉토리·파일 둘 다) pesde의
워크스페이스 링크가 전부 심볼릭이라, 그냥 `luau`로 돌리면 스모크 2개가
죽고 `luau-analyze`는 **조용히 통과**한다(모듈을 `any`로 떨어뜨림 —
"거짓 클린"). 그 스크립트가 `scripts/relink.sh`를 먼저 돌려 심볼릭을 실제
복사로 바꾼다. 핵심 아키텍처(Store 책임 분리, `process`/`retract`
디스패치 모델, Store/State/Source 온톨로지, 소스 트리 구조, Modifier 메커니즘,
컴포넌트=플레인 함수, 컴포넌트 경계 modifier/Ref 전달)는 전부 `.claude/base/`
문서로 확정돼 있음 — 먼저 `.claude/base/architecture.md`를 읽을 것. 사용자가

View file

@ -1692,8 +1692,8 @@ nil value`, (b) 이름을 `self:Gate(...)`로 고쳐도 `setup`이 핸들러를
## 🟢 `H-36``store-plan.md`가 미해결 항목을 확정처럼 근거로 인용한다
**어디**: `base/store-plan.md`(*"State/Source 그래프 구독이 전부 weak-keyed
GC-native"*) vs `base/source-state-plan.md`의 "⚠️ 미해결 — 중간 State가
살아남는가(구독 엣지의 방향성)" 절.
GC-native"*) vs `base/source-state-plan.md`의 "해소됨 — 중간 State는 `_hold`
살아남는" 절.
**무엇이 어긋나나**: `store-plan.md`는 "그래프 구독이 **전부** weak-keyed"라는
명제를 기정사실로 써서 다른 결론(이중 해제 걱정 불필요)을 내리는데, 그 명제

View file

@ -0,0 +1,899 @@
# 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-55`~`H-72`, `H-79`~`H-82`, `H-84`~`H-92`, `H-94`~`H-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-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`가 이걸로 닫힌다:
```lua
if not emit() then -- 보류분 없었음
window = nil -- 완전 idle 복귀 → 타이머 체인 종료
else
rearm()
end
```
### `H-72``EpochMap:Peek(from) -> boolean` 추가 **(확정)**
갈래 (a). 읽기 전용 비교(저장된 리비전과 비교만 하고 덮지 않음).
`Update`가 이미 `{읽기, 비교, 쓰기}``Peek`은 그 앞 두 개만 쓰는 것이고
내부 코드 공유가 쉽다. `GateNode:_receive`가 이걸 쓴다:
```lua
local valueChanged = self.valueEpochMap:Update(from)
local emitChanged = self.emitEpochMap:Peek(from) -- 갱신 안 함
```
### `H-63` — 세 자리 모두 선례대로 **(확정)**
1. onunblock 핸들 보관은 **weak-키 해시맵 셋** `{[handle] = true}`
(`__mode = "k"`) — `H-7``Ref.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 클로저"* 표현을
고친다.
```lua
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` — 캐치업은 조건부, 그리고 **미설치면 재설치** (확정)
```lua
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의 일반 동작이라서."*
```lua
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 도, 템플릿도 필요하지 않다."*
```lua
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-85``rawInvalid` 불린을 **캐시 카운터 쌍**으로 교체 (확정)
원문의 갈래 (a)(`rawInvalid = false`를 `fn` 앞으로)는 **불충분하다**
**사용자 지적**: *"이러면 대신, 캐시가 언제 생성된 캐시인지 모르는 이슈가
발생하지 않아? 특히 에러가 난다고 하면, 다시 계산 안하고 이전 결과를 다시
쓰겠네?"* 그리고 *"state 는 epoch 를 구현해선 안 돼. 중간이지, 초기 값
컨테이너 계층은 아니거든. 따라서 cache count 를 넣을것을 추천해."*
```lua
-- 생성 시
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. *"`:List`의 `updateFn``offset`을 인자로 받는 사용자 코드라 거기서
형제 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 은 여전히
유효하다는거죠."* — `getOffsetAt``for i = bk.invalidAfter, at - 1`
같은 읽기). **splice는 `j`가 아니라 `j - 1`로 낮춘다** — 밀린 자리는
자기 offset도 다시 `Set` 해야 하므로.
```lua
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`
어차피 다시 쓰기로 한 "위험한 패턴" 절에 **세 슬롯 표**를 넣는다:
| 슬롯 | `SetStrong` | `SetWeak` |
|---|---|---|
| 바깥 키(`inst`) | weak | weak |
| **내부 키(`key`)** | **강함** | **강함** |
| 값(`value`) | 강함 | weak |
지금 그 절은 위험을 **값** 기준으로만 서술해 내부 키 슬롯이 아예
등장하지 않는다.
---
## 🅖🅗 Store 표면 · 확정 타입 vs 확정 관용구 — 진행 중
### ⛔ `H-73`**무효**. `<<T>>`는 값 호출부에서 동작한다 (실측으로 뒤집힘)
**사용자 지적**으로 재실측했다 — Luau에는 **generic type instantiation**
문법이 있고(`luau.org/types/generics/#generic-type-instantiation`), 값
호출부에서도, **콜론 메소드에서도** 동작한다.
```lua
--!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`의 두
한계에 걸린다는 걸 확인하고 대안을 탐색했다.
**최종 확정 형태** — 타입 함수를 **안 쓴다**:
```lua
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` (확정)
```lua
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.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`** `getOffsetAt``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`(구현 체크리스트) 셋에
흩어져 있었고, 첫 반영은 앞의 둘만 고쳤다 — **구현자가 실제로 보는 건
세 번째**다.

View file

@ -258,7 +258,7 @@ Observer 값을 키로 `BindData`에 gcconn을 복사하므로, 집합의 원소
`H-23`이 스냅샷을 확정했지만 그 스냅샷 안에서 **무엇을 호출하는지**는
여전히 산문뿐이다.
**이건 `question.md`"중간 State GC" 미해결과 별개다** — 그쪽은 자식
**이건 `question.md` 최우선 절의 중간 State GC 미해결과 별개다** — 그쪽은 자식
노드가 *살아남는가*, 이쪽은 살아있어도 *호출되는가*.
**필요한 것**: 전파 루프의 실제 의사코드 — 구독자가 State 노드면
@ -909,7 +909,7 @@ store 테이블이 아니라 **내부 백킹 테이블**에 넣고 `__index`가
## 🔴 `H-75` — 평평한 `WrapStore``store.key:Compute(무주석 콜백)`이 깨진다 (실측)
**어디**: `base/store-plan.md``WrapStore`/`ProcessStoreType` 스케치,
`luau-test/done/16-type-store-key-typefunction.luau`,
`luau-test/rewrite-required/16-type-store-key-typefunction.luau`,
`base/typing-limits.md` §5(*"✅ 검증 완료"*)와 §1의 ②쪼개기.
**무엇이 어긋나나**: 확정된 `WrapStore``Get`/`Set`만 있는 **평평한**
@ -2094,8 +2094,8 @@ GC 근거를 그에 맞게 완화한다(그러면 테이블 리비전 기각 근
## 🟢 `H-93` — 중간 State GC 미해결은 "전파 끊김"만이 아니라 "최신이라고 오판"으로도 나타난다
**어디**: `base/state-epoch-plan.md` §3의 *"키는 weak다. `epoch`가 죽으면
항목이 사라진다"*, `base/source-state-plan.md`의 "미해결 — 중간 State가
살아남는" 절.
항목이 사라진다"*, `base/source-state-plan.md`의 "해소됨 — 중간 State는 `_hold`
살아남는" 절.
**미리 밝힘**: 이건 `question.md` 최우선 항목의 **파생**이라 이 라운드의
검사 대상이 아니다(1차 패스가 명시적으로 뺐다). 그래도 적는 이유는 그
@ -2436,7 +2436,7 @@ Connection까지 이미 구현해뒀다. 즉 gcconn 트릭을 흉내낼 재료
**어디**: `base/source-state-plan.md`
"Observer의 `:Subscribe()`/`:Unsubscribe()`" 절, 같은 문서의
"미해결 — 중간 State가 살아남는가" 절.
"해소됨 — 중간 State는 `_hold`로 살아남는다" 절.
**무엇이 어긋나나**: `:Subscribe()` 절은 두 가지를 **공개 계약으로**
못박았다 — *"`state:Observer(fn):Subscribe()`처럼 참조를 아무 데도 안

View file

@ -238,7 +238,7 @@ D-7의 재역전 여부, N-4의 `NoneHandler`/`NilHandler` 역할 분담, ST-2
오버로드 함수 타입으로 정확히 추론하려는 시도는 포기하고…"
- `base/store-plan.md:128` — "`store "key"` 문자열 커링은 동적 키가 필요할
때 쓰는 미타입(`Source<any>`) 폴백으로 격하."
- `base/store-plan.md` "남는 것" 문단 — "`myStore "key"`(문자열 커링)는 …
- `base/store-plan.md`의 값 설정 문법 절 — "`myStore "key"`(문자열 커링)는 …
그대로 유지"
- `base/bind-system-plan.md:157` — "`store "key"`(문자열 커링, 동적 키
폴백)는 이벤트와 달리 …"

View file

@ -658,7 +658,7 @@ function Dispatch.process(inst, k, v, index) -- ← 오케스트레이터
|---|---|
| `ML-5` | 사용자가 회신에서 직접 짚음(*"새로운 커밋에서 이것이 달라짐"*). 파일마다 `Relate()`+`INITED` 센티널을 두는 설계가 **`RunInit` 하나로 통합**됐다 — **함수 자기 자신을 릴레이션 키로** 쓰므로 센티널이 불필요. `quad-base/src/init.luau`에 실제 구현 + `test/smoke.init.luau`로 검증까지 끝. **문항 `ML-5`는 폐기.** |
| `ML-8` | 위 변경에 맞춰 `RunInit`(함수 identity) vs `_initializedBy`(문자열 마커) 구분으로 재서술됨. **결론은 그대로**(둘은 다른 층위, 재사용 안 함)이라 문항 자체는 여전히 유효. |
| `ST-3` | "M0에서 실측 확인할 것"이 **실측 완료**됨 — `luau-test/done/21-type-store-undeclared-key-rejected.luau`가 미선언 키 접근 2건이 정확히 `TypeError`로 거부됨을 확인. **⚠️ 열린 항목이 아니게 됨.** |
| `ST-3` | "M0에서 실측 확인할 것"이 **실측 완료**됨 — `luau-test/rewrite-required/21-type-store-undeclared-key-rejected.luau`가 미선언 키 접근 2건이 정확히 `TypeError`로 거부됨을 확인. **⚠️ 열린 항목이 아니게 됨.** |
| `D-10` | 위 B-2 — 스파이크 `01`이 이미 통과. |
| `A-2` | 패키징이 **wally → pesde**로 바뀌었고 `mise`/`selene`가 도입됨. 문항의 "wally 패키지" 표현이 stale(모놀리식 모노레포라는 **결론 자체는 유지**). |
| 전반 | `M0`/`M1`이 **완료**됐다 — 문항지 여기저기의 "M0 착수 전/M0에서 확인" 표현은 이제 "M2 착수 전"으로 읽어야 맞는 것들이 섞여 있다. |

View file

@ -12,35 +12,30 @@
---
## ⭐ 최우선 — **M2(반응형 코어) 착수 전에 답이 필요한 것 둘** (2026-08-24 갱신)
## ⭐ 최우선 — **비어 있음** (2026-08-25 갱신)
> **[2026-08-24] M2/M3 마일스톤 경계 문제는 닫혔습니다.** 그건 설계 결정이
> 아니라 순서 문제라 이 절이 아니라 별도 항목으로 뒀었는데, 사용자가
> **(a) 순서 교체**(반응형이 M2, 디스패치가 M3)를 선택해 같은 날 전량
> 반영됐습니다 — 결정과 근거는 `archive/question-resolved.md`
> "마일스톤 경계" 절, 새 마일스톤 구성은 `ROADMAP.md`의 M2 배너.
> **[2026-08-25] M2(반응형 코어) 착수를 막는 항목이 하나도 없습니다.**
> 2026-08-24에 이 절로 올라왔던 둘이 7라운드 손 트레이싱 후속에서 같이
> 닫혔습니다 — 결정과 근거는
> `qa-request/pre-implementation-handtrace-round7-followup.md`가 소스.
>
> **⚠️ 다만 그 교체의 부작용으로 아래 둘이 "한 마일스톤 뒤"에서
> "바로 다음"으로 올라왔습니다.** 둘 다 반응형(옛 M3, 지금 M2)의 게이트라
> 예전엔 급하지 않았는데, 반응형이 먼저 지어지게 되면서 **지금 답이
> 필요합니다.** 순수한 *설계* 결정 대기는 여전히 0건입니다 — 하나는 실측
> 미완이고 하나는 표면 위치 선택입니다.
- **[신설, 2026-08-18 구현 전 QA] 중간 State GC 미검증** — `State → State →
State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도
없어 전파가 조용히 끊길 수 있음. 방향(상류 strong / 하류 weak)은 사용자가
지목했고, **명문화 여부 결정 + `luau-test` 실측이 M2 착수 전에 필요**
`base/source-state-plan.md`의 "미해결 — 중간 State가 살아남는가" 절.
- **[신설, 2026-08-18 커밋 전 `/code-review high`] `store:GetDynamic`
콜론 메소드로 둘지, 탑레벨 함수로 둘지** — 콜론 메소드로 두면 Store의
lazy `__index`(없는 키를 인덱싱하면 그 자리에서 `Source`를 만들어 저장)와
부딪혀서, `__index`가 고정 메소드 테이블을 먼저 확인해야 하고 그 결과
**`GetDynamic`이 모든 Store의 예약 키 이름**이 된다(그 이름의 Source는
dot-access로 못 만듦). Store 키는 사용자 도메인 데이터 이름이라 충돌
확률이 `Modifier`의 예약 이름들보다 높다. 대안은 탑레벨
`getDynamic(store, name)` — "특정 프리미티브에 안 묶인 범용 유틸은 소문자
탑레벨"이라는 기존 네이밍 규칙에는 오히려 더 맞는다. **M2/M4 착수 전
필요**, `base/store-plan.md`의 "타입 추론 문제" 절.
> - **중간 State GC 미검증****닫힘.** 사용자 확정: *"단순히, 각 state
> 들이 상위 State|Source 를 홀드하는 `_hold` 를 놓는것으로 바로
> 해결된다. 당연히 후행은 선행 요소들이 있어야하기 때문."* 불변식은
> **하류 → 상류 강함(`_hold`) / 상류 → 하류 weak(구독자 집합)**이고
> `base/source-state-plan.md`에 명문화됐습니다. 실측 스파이크는 여전히
> `luau-test`에 하나 두는 게 좋지만 **착수 게이트는 아닙니다**.
> - **동적 키 표면(옛 `GetDynamic`) 위치****닫힘.** `store:Of<<T>>(name)`
> 하나로 합쳤고(콜론 메소드 유지),
> 예약 키 충돌은 `CheckReserved` 타입 함수가 잡습니다. 애초에 이 항목이
> 섰던 근거(lazy `__index`와의 충돌)는 **lazy 생성 자체가 폐기**되며
> 소멸했습니다(명시적 초기화). 이름도 `store:Of<<T>>(name)` 하나로
> 합쳐졌습니다 — `base/store-plan.md`의 "타입 추론 문제" 절.
>
> **[2026-08-24] M2/M3 마일스톤 경계 문제도 닫혀 있습니다** — 사용자가
> **(a) 순서 교체**(반응형이 M2, 디스패치가 M3)를 선택해 전량 반영됐습니다.
> 결정과 근거는 `archive/question-resolved.md`의 "마일스톤 경계" 절, 새
> 마일스톤 구성은 `ROADMAP.md`의 M2 배너.
> **M0 착수를 막던 항목이 전부 해소됐습니다.** `0-Y`(`:Compute(fn)`의
> lazy 핸들 계약)는 열세 번째 세션에, `0-Z`(Attribute 이름 소유권)와

View file

@ -38,7 +38,7 @@
1. **초기화**`RobloxFactory(QuadBase)`로 base+backend 조립 (`module-lifecycle-plan.md`, `bind-system-plan.md`)
2. **Instance 만들기** — DOMless 즉시 생성 모델, 제네릭 생성자 `New` + 클래스별 정적 필드(**[2026-08-18]** 범위는 "GUI에 쓰이는 모든 인스턴스", 전량 코드 생성)(`Frame`, `TextButton` 등) (`architecture.md`, `bind-system-plan.md`)
3. **속성 채우기**`[Attribute "Name"]`, ~~`[Tag ""] = true`~~ **[2026-08-13 정정] 구모델(폐기, `archive/tag-hash-key-model-reversed.md`) — 실제로는 `Tag(...)` array-part 값 객체** 특수 바인드 키 (`architecture.md`)
4. **반응형 기초**`Source`/`Store` 생성, `store.key`(dot-access)로 Source 읽기(Source는 State를 만족), `store.key:Set(value)`로 쓰기, State는 항상 읽기 전용 (`base/source-state-plan.md`, `base/store-plan.md`; 2026-08-06 후속 세션에서 dot-access가 Source를 직접 반환하고 쓰기가 `:Set()`으로 바뀜)
4. **반응형 기초**`Source`/`Store` 생성, `store.key`(dot-access)로 Source 읽기(Source는 State를 만족), `store.key:Set(value)`로 쓰기, State는 항상 읽기 전용 (`base/source-state-plan.md`, `base/store-plan.md`; 2026-08-06 후속 세션에서 dot-access가 Source를 직접 반환하고 쓰기가 `:Set()`으로 확정. **[2026-08-25]** 같은 모델이 유지되고, **생성이 명시적 초기화로** 바뀌었다 — 타입 인자에 `Source<T>`를 직접 쓰고 `defaults`에도 `Source(v)`를 직접. **초심자 트랙이 짚을 것**: 부모가 값을 다 안 넘겨도 되게 하려면 컴포넌트가 자기 `DEFAULTS`로 채워 넘긴다)
5. **스타일링** — Modifier 기본 체이닝(`:FontSize(14)`), 배열/인라인 merge 우선순위 규칙 (`modifier-plan.md`)
6. **자식 전달** — Slot 기본 개념(children 배열, add/remove/clear), 마운트된 slot 재마운트 시 throw (`slot-plan.md`)
7. **컴포넌트 작성** — 컴포넌트 = 순수 함수, 리프 프로퍼티엔 State만 바인딩, 전역 store 직접 참조 금지(이식성) (`component-composition-plan.md`, `purity-and-effects-plan.md`)

View file

@ -306,7 +306,7 @@ base 인터페이스가 그보다 늦은 M8에서 만들어지는 순서 역전.
이름이 아니라 구조로 일치를 검사하므로 문제없음. 기술적으로 막힐 위험이
없어졌으니 M0/M2 어느 시점에 검증해도 무방 — `ROADMAP.md` 배치를 억지로
안 옮겨도 됨. 상세는 `base/typing-limits.md` "`store.key` 레코드 필드
타이핑" 절, 실제 문법 실측은 `luau-test/done/16-type-store-key-
타이핑" 절, 실제 문법 실측은 `luau-test/rewrite-required/16-type-store-key-
typefunction.luau`(**[2026-08-15] 통과** — 원래 스파이크가 API 버전
드리프트로 깨져있던 걸 고침, `audit/type-recursive-issue-with-typeof/
REPORT.md` 6-1절). 아래는 원래 발견 당시 기록.
@ -674,7 +674,14 @@ Handler"라고만 서술해, 사실상 3개의 거의 동일한 형태(리터럴
- `architecture.md`의 "Store/State/Source 온톨로지 — 확정됨 (요약)" 절이
`source-state-plan.md`의 최신 재구성(Source가 State를 만족, `store.key`
Source를 직접 반환, `store.key:Set()`)을 못 따라가고 있던 것 — 이 감사
Source를 직접 반환, `store.key:Set()`)을 못 따라가고 있던 것 —
**⚠️ [2026-08-25 재오픈] 이 "고침"은 다시 낡았다가 다시 맞게 됐다.**
같은 날 오전에 `architecture.md`가 "`store.key`는 값" 재설계로 고쳐졌고,
오후에 그 재설계가 철회되며 원래 모델로 되돌아왔다
(`archive/store-value-field-redesign-withdrawn.md`). **지금 맞는 서술은
`base/store-plan.md`가 소스** — 이 목록의 "이미 고침"만 믿고 넘어가지
말 것(실제로 그 철회에서 `architecture.md` 두 자리가 누락됐고 감사가
잡았다). 이 감사
세션 도중 발견해 직접 정정(커밋 `4b839b0`에서 별도로 이미 반영됨을 뒤늦게
확인 — 같은 문제를 두 세션이 독립적으로 발견한 셈).
- `architecture.md` 소스트리의 `Store.luau`/`Ref.luau` 주석이 각각 옛

View file

@ -52,9 +52,11 @@ v1(`.claude/initreq/quad/src`) 조사 결과, API는 성격이 다른 두 계층
CLAUDE.md에 그런 서술은 없었음. 실제 소스는
`base/component-composition-plan.md`의 "1. 컴포넌트 = 그냥 함수, "자기
store 자동 소유" 매직은 폐기" 절(자동 흡수/자동 store 생성 매직 폐기,
사용자 확정)과 `base/store-plan.md`의 "Store 값 설정 문법 —
`myStore.key = value` 폐기, `source:Set(value)`로 전환" 절(`__newindex`
자동 위임 폐기)).
사용자 확정)과 `base/store-plan.md`의 "Store 값 설정 문법" 절(`__newindex`
자동 위임 폐기)). **[2026-08-25]** 여기 한때 *"`store.key = v`는
되살아났다"*고 적었는데 그건 같은 날 **철회된** 재설계다 — 대입 문법
폐기(2026-08-06)는 **유지**된다
(`archive/store-value-field-redesign-withdrawn.md`).
## 3. 계층별 실현 가능성

View file

@ -5,7 +5,38 @@
(`.claude/question.md`, `luau-test/STATUS.md` 등).
00. **⭐⭐ [2026-08-18 신설] 구현 전 QA — [2026-08-21] 1~5라운드 전부 `base/`
00. **⭐⭐⭐ [2026-08-25] 7라운드까지 전부 처리 완료 — `question.md` 최우선
절이 비었고 M2 착수 게이트가 0이다.** 7라운드(손 트레이싱, 6패스,
발견 52건 `H-55`~`H-106`)와 그 검증 패스를 사용자와 대화형으로 처리해
`base/`에 전량 반영했다. **결정의 소스는
`qa-request/pre-implementation-handtrace-round7-followup.md`**(발견 원문은
`qa-request/pre-implementation-handtrace-round7.md`, 판정은
`qa-request/pre-implementation-handtrace-round7-verification.md` — 개수·개별 항목은 여기서
세지 않는다).
- **구조가 바뀐 것**: Store — **명시적 초기화**(옛 eager/lazy 이중 모델
폐기, `defaults`가 곧 선언 키 집합) + `WrapStore`/`ProcessStoreType`
폐기(**타입 함수를 안 쓰고** 타입 인자에 `Source<T>`를 직접) +
동적 키를 `store:Of<<T>>(name)` 하나로(옛 `GetDynamic` 흡수),
`Ref``Epoch`로 승격, `Weak*` 등록 표면 신설(`Ref:WeakCallback` /
`Observer:WeakSubscribe`), `Effect`의 강한 dep 맵 + `Blocker` 억제,
전파 루프 의사코드 확정, `rawInvalid` → 캐시 카운터 쌍,
`recompute` 재진입 차단 + 되감기, `emit(commit) -> boolean`,
`EpochMap:Peek`, `_hold` 불변식.
- **⚠️ 같은 날 한 번 크게 되돌렸다**: 오전에 "`store.key`가 값,
`store:Of(k)`가 프리미티브, `index<>`/`keyof<>` + 팬텀 필드,
`store.key = v` 부활"이라는 Store 재설계를 넣었다가 **같은 날
철회**했다 — 살아남은 것은 명시적 초기화 하나다. 철회 이유와 원문은
`archive/store-value-field-redesign-withdrawn.md`, 거기서 나온 원칙
(*"타입 함수는 진단까지만"*)이 `base/typing-limits.md` §0으로 승격됐다.
- **역전 없음**`store.key = value` 폐기(2026-08-06)는 **유지**된다.
- **툴체인 하나**: `luau` CLI가 심볼릭 링크를 못 탄다는 게 밝혀져
`scripts/relink.sh` + `scripts/test.sh` 신설 — 그 전엔 스모크 2개가
안 돌고 `luau-analyze`가 **거짓 클린**을 줬다.
- **새 error 계약**: `level` 이분(사용자 입력 2 / 내부 불변식 1),
메시지는 **영어**, 예외는 `pcall`로 안 감싼다
(`base/architecture.md`의 두 신설 절).
**[2026-08-21] 1~5라운드 전부 `base/`
반영 완료. ⭐ 같은 날 마지막에 `Gate`와 State 에포크까지 확정되면서
**M2 착수를 막는 설계 항목은 더 이상 없다.**** `Gate`
`state:Gate(setup)` + `GateNode`로(`base/gate-plan.md`), State의
@ -166,7 +197,15 @@
(**[2026-08-24]** 그때 열어뒀던 마일스톤 재편 여부가 순서 교체로
닫혔다 — 위 00번 머리말).
**⚠️⚠️ [2026-08-24 승격] 아래 둘은 이제 *바로 다음* 마일스톤의
**✅ [2026-08-25 해소] 아래 둘은 전부 닫혔다** — `question.md` 최우선
절은 지금 **비어 있다**. 중간 State GC는 **`_hold` 불변식**(하류 → 상류
강함, 상류 → 하류 weak)으로 사용자가 확정했고, 동적 키 표면(옛 `GetDynamic`) 위치는
**콜론 유지 + `CheckReserved` 타입 함수**로 닫혔다(애초에 그 항목이 섰던
근거인 lazy `__index` 충돌 자체가 Store 재설계로 소멸). 남은 건 실측
스파이크 하나뿐이고 **착수 게이트가 아니다**(`luau-test/STATUS.md`의
"만들어야 할 스파이크" 절). 아래는 해소 전 서술:
**⚠️⚠️ [2026-08-24 승격, 2026-08-25 해소됨] 아래 둘은 이제 *바로 다음* 마일스톤의
게이트다.** 순서 교체 전엔 반응형이 M3라 "한 마일스톤 뒤"의 일이었는데,
반응형이 M2가 되면서 **지금 착수 직전에 결론이 필요한 항목**이 됐다
(위 00번이 "M2 착수를 막는 **설계** 항목은 없다"고 하는 것과 모순되지

1
.gitignore vendored
View file

@ -14,3 +14,4 @@ lune_packages/
# luau-lsp가 rojo 설치를 감지하면 백그라운드에서 자동 생성/watch함
# (에디터 타입 링킹용 산출물, 커밋 대상 아님)
sourcemap.json
.relink-manifest

View file

@ -8,9 +8,10 @@ M2(반응형 코어 — Source/State/Store)**. **⚠️ [2026-08-24] M2와 M3의
**2026-08-24 이전에 쓰인 `session/`·`archive/`·`qa-request/` 문서의
`M2`/`M3`는 옛 의미로 읽을 것**(라이브 문서는 전부 새 번호로 맞춰뒀음).
경위는 `.claude/archive/question-resolved.md`의 "마일스톤 경계" 절,
새 구성은 `ROADMAP.md`의 M2 배너. **⚠️ M2 착수 전에 답이 필요한 항목이
남아 있다** — 무엇이 몇 개인지는 여기서 세지 말고 `.claude/question.md`
최우선 절을 볼 것. 같은 상태를 `.claude/project-context.md`
새 구성은 `ROADMAP.md`의 M2 배너. **⭐ [2026-08-25] M2 착수를 막는 항목은
없다** — `.claude/question.md`의 최우선 절이 **비었다**(7라운드 손 트레이싱
후속에서 중간 State GC와 `store:GetDynamic` 위치가 같이 닫혔다, 결정 전량의
소스는 `.claude/qa-request/pre-implementation-handtrace-round7-followup.md`). 같은 상태를 `.claude/project-context.md`
서술하니 마일스톤이 넘어갈 때 두 곳을 같이 고칠 것. 진행 상황의 소스는
항상 루트 `ROADMAP.md`.

View file

@ -13,10 +13,12 @@ quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기
> 절이 소스). 그건 아직 열린 작업이므로 "M0 완료"로 읽고 넘어가면 안 됩니다.
> **[2026-08-24] 착수를 막던 마일스톤 순서 문제는 해소됐습니다** — M2와
> M3의 번호를 맞바꿔 반응형을 먼저 짓기로 확정했습니다(아래 M2 배너가
> 소스). **⚠️ 다만 그 교체로 M2 착수 전에 답이 필요한 항목 둘이
> `.claude/question.md` 최우선 절로 올라왔습니다** — 중간 State GC 실측과
> `store:GetDynamic` 위치(원래 반응형의 게이트였는데 반응형이 앞으로
> 오면서 같이 당겨짐). M1까지의 산출물은
> 소스). **⭐ [2026-08-25] 그 교체로 올라왔던 항목 둘도 닫혔습니다** —
> 중간 State GC는 `_hold` 불변식(하류 → 상류 강함)으로, 동적 키 표면
> 위치는 `store:Of<<T>>(name)` 하나로(옛 `GetDynamic` 흡수) + 예약 키
> 충돌은 `CheckReserved` 타입 함수로. `.claude/question.md`
> 최우선 절은 지금 **비어 있습니다**. 결정 전량의 소스는
> `.claude/qa-request/pre-implementation-handtrace-round7-followup.md`. M1까지의 산출물은
> quad-base/quad-roblox 폴더+pesde.toml, 루트
> default.project.json/.luaurc, mock 테스트 하네스, `New()`/`RunInit`/
> `AddPlugin` 골격. **다만 M0의 검증 스파이크 여러 개가 설계 변경으로
@ -195,7 +197,14 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
"미정"이라 적어둔 걸 `H-48`이 닫았다
- [x] quad-base용 최소 mock 테스트 하네스(Vide `test/mock.luau` 선례, 순수
`luau` CLI, `architecture.md` "테스트 전략" 절 참고) —
`quad-base/test/mock.luau` + `smoke.*.luau`, 전부 PASS
`quad-base/test/mock.luau` + `smoke.*.luau`**[2026-08-25 정정,
7라운드 `H-78`] "전부 PASS"엔 전제조건이 있었다.** `luau` CLI가
**심볼릭 링크를 못 타는데**(디렉토리·파일 둘 다) pesde의 워크스페이스
링크가 전부 심볼릭이라, 그대로는 `smoke.init`/`smoke.plugin`이
`could not resolve child component "src"`로 죽고 `luau-analyze`
**조용히 통과**한다(모듈을 `any`로 떨어뜨림 — "거짓 클린").
**`./scripts/test.sh`로 돌릴 것** — 그게 `scripts/relink.sh`를 먼저
돌려 심볼릭을 실제 복사로 바꾼다. 그 뒤 셋 전부 PASS 확인
- [x] 최상위 `New()`/`InitXxx(module)` 팩토리 체이닝 골격 — 각 서브시스템
Init이 `module`을 파라미터로 받아 뮤테이션, `Relate` 기반 인스턴스별
멱등 가드(`base/module-lifecycle-plan.md`의 "New()의 내부 구성" 절
@ -325,33 +334,68 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`:TrackFrom``EpochSet = {[Epoch]: true}`, **배열 아님**)
으로 떼어내며, State가 그걸 **둘** 컴포지션한다 — `valueEpochMap`(값
유효성)/`emitEpochMap`(전파 dedup). emit은 값도 리비전도 안 싣고
**출처(`Epoch`나 그 집합)만** 싣고, 순회는 `rawInvalid == false`일 때만
**출처(`Epoch`나 그 집합)만** 싣고, 순회는 **캐시 카운터가 같을 때만**(옛 `rawInvalid == false` 자리 — **[2026-08-25 `H-85`]** 불린이 `cacheTargetCount`/`cacheCurrCount` 쌍으로 교체됐다)
돌며 **값만 앞당기고 통지는 상류 emit을 기다린다**. 다이아몬드 중복
통지가 접히므로 스파이크 `05`도 그에 맞춰 재작성해야 한다
(`luau-test/STATUS.md`).
- [ ] `Source.luau`/`State.luau`/`Store.luau`
- [ ] **[2026-08-18 신설]** `store:GetDynamic<<T>>(name): Source<T>` — 런타임에
이름이 정해지는 동적 키의 정식 창구(옛 `store "key"` 문자열 커링은
기각). **⚠️ 콜론 메소드로 두면 `__index`가 고정 메소드 테이블을 먼저
확인해야 하고 `GetDynamic`이 예약 키가 됨** — 탑레벨 함수로 둘지
아직 미결(`base/store-plan.md`의 "타입 추론 문제" 절, `question.md` 최우선 절)
- [ ] **[2026-08-18 신설, 2026-08-25 확정]** `store:Of<<T>>(name): Source<T>`
런타임에 이름이 정해지는 동적 키의 정식 창구(옛 `store "key"` 문자열
커링은 기각). **콜론 메소드로 확정**했고, 예약 키
(`Of`/`Names`) 충돌은 `CheckReserved` 타입 함수가 사용 지점에서
잡는다(`base/store-plan.md`의 "타입 추론 문제" 절).
`<<T>>`가 값 호출부에서 실제로 `T`를 묶는 것도 실측 확인됨.
**[2026-08-25] 옛 이름은 `GetDynamic`이었다 — `Of`가 흡수했다**
- [ ] **[2026-08-25 신설]** `store:Names(): { string }` — 선언된 키 집합
열거(그림자 테이블의 키). 그룹 `Attribute(...)`/`attr:NameMap()`이
요구한다(`base/attribute-plan.md`)
- [ ] **[2026-08-25 신설]** `CheckReserved` 타입 함수 — 예약 키를 **검증만**
하고 `T`를 그대로 통과시킨다. `error()`가 아니라
`print(...)` + `return types.never`를 써야 한다.
**타입 함수는 이 용도(진단)까지만 쓴다**`base/typing-limits.md` §0
- [ ] **[2026-08-25 신설]** **명시적 초기화** — 타입 인자에 `Source<T>`
직접 쓰고 `defaults`에도 `Source(v)`를 직접 넣는다. 옛 lazy `__index`
(없는 키를 그 자리에서 만들어 저장)는 **폐기**. 그래서 `defaults`
곧 선언 키 집합이고 `Names()`가 성립한다
- [ ] **State 전파 루프 — 구독자는 weak, 발화마다 `canExecute` 게이팅**
(2026-08-14 다섯 번째 세션 확정, `base/lifecycle-pattern.md`의 "실제
호출부 — State 전파(`emit`)가 `canExecute`로 게이팅한다" 절) —
State는 구독자(Observer의 emit 클로저)를 **weak로만** 담고, 살려두는
State는 구독자를 **weak-키로만** 담고, 살려두는
책임은 `gchold`(leaf) 또는 전역 `Subscribed` 테이블(전역)에 있음
(어디에도 안 묶인 Observer는 GC되어 목록에서 자연히 빠짐). 발화 시
각 구독자에 대해 `canExecute(observer)`가 거짓이면 **그 구독자만
조용히 건너뜀**(no-op) — 이게 `canExecute`의 유일한 실제 호출부이고,
`inst`를 인자로 받을 수 없는 이유(State는 자기가 어느 Instance에
걸렸는지 모름). `state:Observer(fn)`의 "등록 즉시 1회 실행"은
`bindLifetime` 이전에 동기적으로 일어나므로 이 게이팅과 무관
- **[확정된 것 — 코드 아님]** `store.key` dot-access 타입 추론 확인 — Luau `type function`
(`WrapStore`/`ProcessStoreType`)으로 `Store<T>``T`의 각 필드를
`Source`로 감싼 레코드 타입을 합성 가능함을 확인(2026-08-12 열일곱
번째 세션, `base/typing-limits.md` §5) — **[2026-08-15 실측 완료]**
`luau-test/done/16-type-store-key-typefunction.luau` 통과(원인은
설계 문제가 아니라 `types.newfunction` API 버전 드리프트였음)
(어디에도 안 묶인 Observer는 GC되어 목록에서 자연히 빠짐).
**⭐⭐ [2026-08-25 정정, 7라운드 `H-56`] 확정 의사코드는
`base/source-state-plan.md`의 "전파 루프 — 확정 의사코드" 절이
소스다.** 여기 한때 적혀 있던 두 가지가 틀렸었다 —
(a) 집합의 원소는 "Observer의 emit 클로저"가 아니라 **Observer
값**이다(`bindLifetime`이 그 identity를 쓰므로 클로저를 담으면
`canExecute`가 항상 거짓), (b) **`canExecute`는 Observer/Effect
구독자에만 적용된다 — 자식 State 노드는 이 게이트를 안 탄다.**
"각 구독자에 대해"로 짜면 `:With`/`:Compute`/`:Gate`가 만든 파생
노드가 **전부 걸러져** 그 아래 모든 Observer가 침묵한다. 자식
노드의 생존은 `canExecute`가 아니라 같은 문서의 **`_hold`
불변식**(하류 → 상류 강함)이 책임진다.
```lua
if isState(sub) then sub:_receive(from) -- 자식 노드: 게이트 없음
elseif canExecute(sub) then sub.fn(sub, from) -- Observer / Effect
end
```
`canExecute``inst`를 인자로 받을 수 없는 이유는 그대로다(State는
자기가 어느 Instance에 걸렸는지 모름). `state:Observer(fn)`
"등록 즉시 1회 실행"은 `bindLifetime` 이전에 동기적으로 일어나므로
이 게이팅과 무관.
**순회 전 스냅샷 필수**(`H-23`) — `pairs` 순회 중 새 키 추가는
미정의다
- **[확정된 것 — 코드 아님]** `store.key` dot-access 타입 추론 확인 —
**[2026-08-25 재작성]** 옛 `WrapStore`/`ProcessStoreType` 합성 접근은
**폐기**됐고(`archive/store-value-field-redesign-withdrawn.md`), 지금은
**타입 함수를 안 쓰고** 타입 인자에 `Source<T>`를 직접 써서 평범한
레코드로 짓는다(같은 날 `index<>`/`keyof<>` + 팬텀 필드 안을 넣었다가
§0 원칙에 따라 철회 —
`archive/store-value-field-redesign-withdrawn.md`). `luau-analyze`로 양성 9건 +
음성 대조군 전부 확인 — `base/store-plan.md`
"`store.key` 레코드 필드 타이핑" 절이 소스. 스파이크 `16`/`21`은
폐기된 접근을 검증한 것이라 재작성 대기
(`luau-test/STATUS.md`)
- [ ] `:Compute(fn, ...)` — trailing args로 추가 의존성 직접 받는 sugar
(2026-08-11 세션, `base/source-state-plan.md` "`:Compute(fn, ...)`"
절) — `:With(...):Compute(fn)` 체인과 달리 노드 1개(Compute 노드
@ -366,15 +410,28 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
절, 2026-08-07 일곱 번째 세션) — `factory(self)`를 체이닝 문법으로
부르는 순수 설탕, `factory: (State<T>) -> U): U`로 열린 타입. Source도
기존 `:With`/`:Compute` 델리게이션에 얹혀 자동 포함
- [ ] **`Observer.luau`** — `Observer` 객체와 **`:Subscribe()`/`:WeakSubscribe()`
전역 레지스트리의 소유 모듈**. `EpochMap.luau`와 같은 이유로
`State.luau`에 묻지 않는다(`Effect`/`GateNode`/leaf 핸들러가 전부 이
레지스트리를 본다) — `base/architecture.md` 소스 트리, 7라운드 `H-99`
- [ ] `state:Observer(fn)` — children 배열 leaf 참가자, **등록 즉시 1회
실행 확정**(`base/source-state-plan.md`의 Observer 절), `isObserver`
판별자, canExecute 게이팅, `:Subscribe()`/`:Unsubscribe()`. **동적
판별자, canExecute 게이팅, `:Subscribe()`/`:Unsubscribe()` +
**`:WeakSubscribe()`/`:WeakUnsubscribe()`**(Weak 쪽이 프리미티브,
7라운드 `H-58`/`H-59`). **무인자 `state:Observer()`의 내부 콜백은
`function(self) self:Get() end`**(`H-61`). **동적
경로 가드**(`{priority = HANDLER_PRIORITY_FALLBACK, isHandlable = v
is Observer, process = error(...)}`, `k` 타입 안 가림, 2026-08-14
열한 번째 세션 — `PreRef`와 같은 패턴)도 같이 등록
**⚠️ [2026-08-24] 단 그 가드를 `Dispatch.addHandler`로 등록하는 것
자체는 M3다** — 레지스트리가 거기서 생긴다(M3의 그 항목).
- [ ] `Effect(fn, ...deps)`(`base/effect-plan.md`, **[2026-08-21 5라운드
- [ ] `Effect(fn, ...deps)`**⚠️ 선행: `Blocker`의 기본 메커니즘**
(`On`/`Off`/`IsOn`/`OffWithoutEmit`). 생성자가 등록 구간 억제에 사적
`Blocker` 하나를 쓴다(`base/effect-plan.md`). 아래 `Blocker.luau`
체크박스가 이 항목보다 뒤에 있지만 **그 기본 넷은 `GateNode`/`:Policy`와
무관하게 독립 완결**이라(`base/blocker-plan.md`의 "메커니즘" 절) 그
부분만 먼저 만들면 된다 — `:Policy`/`state:Block` 배선은 `GateNode`
뒤에. (`base/effect-plan.md`, **[2026-08-21 5라운드
`C-6`]** 옛 시그니처는 `Effect(fn, state?)`) — deps 생략 시 설치
1회+leaf 사망 시 확정 정리, deps 지정 시 **각각에 맞는 구독**
(State/Source는 `Observer`, `Ref``:Callback`)을 걸어
@ -389,17 +446,32 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
"동적 경로 가드" 절, 2026-08-14 열한 번째 세션)
**⚠️ [2026-08-24] 단 그 가드를 `Dispatch.addHandler`로 등록하는 것
자체는 M3다** — 레지스트리가 거기서 생긴다(M3의 그 항목).
- [ ] **⭐ [2026-08-24 신설, 6라운드] `Effect` 구현 시 같이 만들 것** —
**`handle._observers`**(배열, 단수 `_observer`에서 바뀜 `H-8`) ·
- [ ] **⭐ [2026-08-24 신설, 6라운드 / 2026-08-25 7라운드로 필드 재편]
`Effect` 구현 시 같이 만들 것** —
**`handle._deps`**(`{[Ref|State] = fn|Observer}`, **강참조** — 옛
`_observers`/`_refDeps`/`_refCallbacks` 셋이 여기로 통합됐다) ·
**`handle._epochs`**(`EpochMap` — `Ref``Epoch`라 dep 종류가 균일) ·
**`handle._blocker`**(등록 구간의 즉시-1회 호출 억제 — 옛 `_installing`
플래그 폐기, 그건 생성자 구간만 덮어 바인드 구간을 놓쳤다) ·
**`handle._cleanup`**(직전 cleanup 보관, `Rerun``Destroying` 클로저가
같은 자리를 읽는다) · **`handle._refDeps`/`_refCallbacks`**(`Ref` dep과
거기 건 클로저 — 해제 시 값으로 떼야 해서 보관 필요) ·
**`handle._destroyConn`** · **`:_bindDestroying(inst)`/`:_unbindDestroying()`**
(`bindLifetime`/`unbindLifetime`이 `isEffect`일 때 부르는 훅, `H-11`).
`fn` 시그니처는 **`fn(self: EffectHandle) -> (() -> ())?`**이고
**`...deps``fn`에 안 넘어간다**(`H-14`). `Ref` 콜백은 본문 맨 앞에서
**`canExecute(handle)`를 확인**한다(`H-7`). 의사코드는
`base/effect-plan.md`가 소스
같은 자리를 읽는다) · **`handle._installed`**(설치 여부 — `fn`의 cleanup
반환이 **선택**이라 `_cleanup`의 유무로는 판정할 수 없다) ·
**`handle._running`/`_pending`**(`Rerun` 재진입
지연) · **`handle._destroyConn`** ·
**`:_bindDestroying(inst)`/`:_unbindDestroying()`**
(`bindLifetime`/`unbindLifetime`이 `isEffect`일 때 부르는 훅, `H-11`) ·
**`:Rerun()`**(정의는 `H-60`이 채웠다 — 실행 중 재진입은 **지연
재실행**, error는 UB) · **`:_consumeCleanup()`**(읽고 → 지우고 → 실행).
`fn` 시그니처는 **`fn(self: EffectHandle) -> ...(() -> ())`**이고
(**[2026-08-25 `H-95`]** 가변 반환 팩 — 옛 `-> (() -> ())?`는 콜백이
"선언보다 적게 반환"할 때 strict에서 막혀 정상 용례가 전부 안 통과했다)
**`...deps``fn`에 안 넘어간다**(`H-14`).
**⭐ dep 등록은 생성자에서 한 번만** — `:WeakSubscribe()`/
`:WeakCallback()`으로 걸고, 바인드/언바인드는 dep을 아예 안 건드린다
(`H-58`/`H-59`). 발화 게이트는 전부 **`canExecute(handle)`** 하나다
(`H-7`). 캐치업은 바인드 직후 **조건부 최대 1회**
(`if not self._installed or self._epochs:Refresh() then self:Rerun() end`
`H-64`/`H-65`). 의사코드는 `base/effect-plan.md`가 소스
- [ ] **[2026-08-24 `H-23`]** State 전파 루프는 구독자 집합을 **배열로
스냅샷한 뒤** 돈다 — 순회 중 새 구독자 추가가 정상 경로인데 Lua에서
미정의라, 실측에서 실행마다 결과가 달라지고 한 Observer가 통째로
@ -435,17 +507,40 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
한 번도 안 쓴다, `base/state-epoch-plan.md` §5). **빈 배치는
아무것도 안 함**. `emitEpochMap`을 쓰므로 위 `EpochMap.luau`가 선행 —
`base/gate-plan.md`. **"게이팅 먼저" 결정의 산물**
(사용자: *"게이팅 먼저. 게이팅을 base 에 만들 준비를 해야한다"*)
(사용자: *"게이팅 먼저. 게이팅을 base 에 만들 준비를 해야한다"*).
**⭐⭐ [2026-08-25 갱신, 7라운드 `H-55`/`H-72`/`H-86`] `setup` 계약이
바뀌었다** — `setup: (emit: (commit: boolean?) -> boolean) ->
(onUpstreamEmit: () -> ())`. 인자 **목록**은 그대로지만
`emit(false)`**흡수 집합을 버리고**(정책이 그걸 할 통로가 없었다),
반환값이 **"실제로 내보내거나 버릴 게 있었는가"**를 준다(정책이
`pending`을 읽을 통로가 없어 `Throttle`의 창이 idle로 못 돌아왔다).
그리고 **수신 시점 판정은 `emitEpochMap:Peek`**을 쓴다 — `:Update`
덮으므로 "유보 중엔 아직 안 던졌다"는 맵의 뜻과 양립하지 않는다.
flush 순서는 **빈 배치 얼리리턴 → 스왑 → `:Sync(batch)` → 전파**
- [ ] **`Blocker.luau`** (**[2026-08-24]** 위 둘과 같이 되돌아옴) — 위 `GateNode` 위에
얹히는 **정책**(다시 노드를 만들지 말 것). 여러 Source를 한꺼번에
얹히는 **정책**(다시 노드를 만들지 말 것).
**⭐ [2026-08-25 추가, 7라운드 `H-63`] onunblock 핸들 보관 세 자리**:
(1) **weak-키 해시맵 셋**(`__mode = "k"`) — 값-weak 배열이면 구멍에서
`ipairs`가 멈춰 뒤의 살아있는 게이트가 `Off()`를 못 받는다,
(2) **강한 주인은 정책이 반환하는 `onUpstreamEmit` 클로저**(upvalue로
잡는다) — Blocker가 강하게 들면 거기 걸렸던 모든 gated state와 상류
체인이 영원히 산다, (3) `Off()`/`OffWithoutEmit()`은 **스냅샷 뒤 순회**. 여러 Source를 한꺼번에
바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive
(`base/blocker-plan.md`). **M3의** `Dispatch.setLength`/`setOffsetSource`의
배치 등록이 `:On()`/`:IsOn()`/`:OffWithoutEmit()` **세 메서드**를
호출하므로(`base/gate-plan.md` 9번이 소스 — Blocker 인스턴스를 lazy
조회하는 `getBlocker(ownerKey)`는 Blocker 메서드가 아니라 Dispatch
쪽 헬퍼다) **최소한 그 셋이 도는 형태까지는 M3(디스패치)가 요구**
- [ ] **[2026-08-24 `H-25` 파생]** `quad-types``Quad``Source`/`State`/
`Store` 필드 추가 — **규칙 요지**: `New(): Quad`가 닫힌 레코드이고
- [ ] **[2026-08-24 `H-25` 파생, 2026-08-25 `H-80`으로 목록 확장]**
`quad-types``Quad`**이 마일스톤이 얹는 탑레벨 값 전부** 추가 —
`Source` / `Store` / `Effect` / `Blocker` / `Relate` /
`is*` 전량(`isState`/`isSource`/`isStore`/`isRef`/`isObserver`/
`isEffect`/`isEpoch`/`isModifier` …) / `bindLifetime`·`unbindLifetime`·
`canBound`·`canExecute` 4종. **⚠️ `State`는 런타임 생성자가 없다** —
파생(`:With`/`:Compute`/`:Gate`)으로만 생기므로 **타입만** 재수출한다.
옛 목록(`Source`/`State`/`Store` 셋)은 M2가 실제로 얹는 것의 일부만
담고 있었고, 빠진 것 전부가 `H-25`가 만든 바로 그 벽에 부딪힌다.
**규칙 요지**: `New(): Quad`가 닫힌 레코드이고
`RunInit`은 반환값이 없어 타입을 못 넓히므로, 서브시스템을 붙이는
마일스톤마다 `quad-types``Quad`에 그 필드와 타입 재수출을 같이
추가한다. 안 하면 그 마일스톤 완료 후 `quad.Store` 접근이 런타임엔
@ -464,7 +559,24 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`luau-test``15-type-compute-trailing-deps-typepack.luau`
이형 다중 deps를 제네릭 타입 팩으로 표현 가능한지만 실측 필요(안
되면 동종 타입 dep 1개로 한정)
- [ ] mock 대상 테스트
- [ ] **[2026-08-25 신설, `H-84`]** `:With(...)` / `state:Block(blocker)` /
`Source:Emit()``:Compute`/`:Apply`/`:Observer`는 각각 체크박스가
있는데 이 셋만 빠져 있었다
- [ ] **[2026-08-25 신설, `H-81`]** `isModifier` 런타임 가드 — 적용 지점이
**전부 이 마일스톤의 코드**다(`Source:Set` / Store 생성 시 eager
`Source(default)` / State의 `:Compute` 결과 캐싱, 그리고
독립 `Source(someModifier)`). 체크박스가 M7에만 있었다.
`base/modifier-plan.md` 7번과 `base/source-state-plan.md`의 적용
지점 목록을 서로 맞출 것(한쪽에만 있는 항목이 있다)
- [ ] **[2026-08-25 신설, `H-97`]** mock 백엔드용 생명주기 4종 최소 구현 —
`bindLifetime`/`unbindLifetime`/`canBound`/`canExecute`. 안 하면
**아래 "mock 대상 테스트"가 전파 루프를 한 번도 못 돈다**(루프가 매
발화마다 `canExecute`를 부르는데 그건 M8 구현이고 미주입 슬롯은
에러 스텁이다). 커밋된 `quad-base/test/mock.luau`에 signal/Connection이
이미 있으므로 그 `Destroying`을 그대로 쓰면 된다
- [ ] mock 대상 테스트 — **전파 루프를 실제로 돌릴 것**(위 항목이 선행).
M2의 핵심이 전파 루프인데 그걸 한 번도 안 돌려보고 M3로 넘어가면
7라운드가 찾은 종류의 결함을 그대로 낳는다
## M3 — 디스패치 엔진
@ -1115,7 +1227,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
- [ ] **[2026-08-24 `H-25` 파생]** `quad-types``Quad``Ref` 필드 추가
(위 M3 항목의 "마일스톤마다" 규칙)
- [ ] `Ref.luau`(`.Value` 읽기 전용 필드 + `:Set(value)`/`:Callback(fn)`/
`:Wait(thread?)`, 전부 self 반환) + `PreRef.luau`/`PostRef.luau`(별도 파일, Ref
`:Wait(thread?)`, 전부 self 반환).
**⭐ [2026-08-25 추가, 7라운드 `H-58`/`H-64`/`H-70`] `Ref``Epoch`
만족하게 됐다** — 공개 필드 **`.Revision`**(`:Set()`이 `Source`와 같은
`bit32.bnot(-rev)`로 갱신) + **`EpochBrand:register(self)`**, 그리고
약하게 등록하는 **`:WeakCallback(fn)`**(weak-키 별도 테이블, `Weak` 쪽이
프리미티브이고 `:Callback`이 그 위에 "GC 킵"을 얹은 것).
**M2가 이미 이 표면을 전제한다**`Effect` 생성자가 `Ref` dep을
`self._epochs:Sync(d)``EpochMap`에 태운다(`base/effect-plan.md`).
`base/ref-plan.md`의 "`Ref`는 `Epoch`를 만족한다" 절이 소스 + `PreRef.luau`/`PostRef.luau`(별도 파일, Ref
런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단,
위치 무관 호이스팅 pre-pass — `base/ref-plan.md` "`phase`
옵션 폐기 → 위치로 표현, `PreRef` 신설" 절 + "API 모양" 절)
@ -1181,17 +1301,30 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
(`pairs` 순회 중 새 키 추가가 Lua에서 미정의라 — `H-23`과 같은 처방)
키 타입으로 분기: `type(k) == "thread"``Callbacks[k] = nil`로 소진 후
`coroutine.resume(k, self)`(값이 아니라 **Ref 자신**), 함수면 `k(value)`
호출 + 유지. **중복 등록은 dedup이 계약**이고 해제는 신설된
호출 + 유지. **중복 등록은 dedup이 계약**이고 해제는
**`:Uncallback(fn)`**(`Callbacks[fn] = nil` 한 줄).
**⭐ [2026-08-25 추가, 7라운드 `H-58`/`H-59`] 약하게 등록하는 짝
`:WeakCallback(fn)`이 신설됐고 그쪽이 프리미티브다** —
`:Callback(fn)`은 거기에 "GC 안 되도록 킵" 하나를 더 얹은 것이다.
약한 등록은 **weak-키 테이블**(`__mode = "k"`)로 `.Callbacks`
**별도**이고(Lua의 weak 모드는 테이블 단위), 발화 순회는 둘 다
훑으며 `:Uncallback`은 양쪽을 본다.
의사코드는 `base/ref-plan.md`가 소스.
**⚠️ 옛 배열 설계(빈 슬롯 선형 탐색 등록, `[i] = nil` 소진, `#t` border
실측)는 전부 폐기됐다** — 해시맵엔 구멍도 border도 없다.
`:Wait(thread?)`는 그대로 — `thread``nil`이면
`coroutine.running()` 캡처+yield, 있으면 등록만 하고 즉시 `self`
반환(남의 thread를 여기서 대신 정지시킬 수 없어서)
- [ ] **[2026-08-24 신설, 6라운드 `H-7`]** `Ref:Uncallback(fn)` — 해제 경로.
`Effect`가 자기 `Ref` dep 콜백을 뗄 때 쓰고(`_refCallbacks`에 보관해둔
바로 그 클로저를 값으로 뗀다), `unbindLifetime`/`:Unsubscribe()`가 부른다
- [ ] **[2026-08-24 신설, 6라운드 `H-7` / 2026-08-25 범위 축소, 7라운드 `H-58`]**
`Ref:Uncallback(fn)` — 해제 경로(강·약 두 테이블을 다 본다).
**⚠️ `Effect`는 더 이상 이걸 안 부른다** — 여기 한때 *"`Effect`가 자기
`Ref` dep 콜백을 뗄 때 쓰고(`_refCallbacks`에 보관해둔 바로 그 클로저를
값으로 뗀다), `unbindLifetime`/`:Unsubscribe()`가 부른다"*고 적혀 있었는데
그 계약은 `base/effect-plan.md`가 명시적으로 retract했다. 지금은
`Effect`**생성자에서 `:WeakCallback`으로 한 번만** 걸고 강한 주인은
`_deps`이며(`_refCallbacks`는 폐기된 필드), 바인드/언바인드는 `Ref`
아예 안 건드린다. `:Uncallback`은 **사용자가 직접 건 콜백을 떼는**
공개 표면으로 남는다
- [ ] `LifetimeHandle` quad-roblox 실제 구현 — `bindLifetime`/
`unbindLifetime`/`canBound`/`canExecute` 본체(인터페이스 자체는
M2로 이동됨, `Relate` 자체는 quad-base라 quad-roblox 쪽 재구현
@ -1202,12 +1335,16 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`gchold[value]=true`(강참조로 생존 보장)와 `BindData:SetWeak(value,
"gchold"/"gcconn", ...)`(값이 자기 생존 판정 근거를 직접 들고 있게)
둘을 하고, `unbindLifetime(value)`은 그걸 되돌림.
**⭐ [2026-08-24 6라운드 `H-11`] 그리고 `isEffect(value)`면 분기가 하나 더
붙는다** — `handle._observers` 전부로 cascade하고
`value:_bindDestroying(inst)` / `:_unbindDestroying()`를 부른다(전자는
주입 op `onDestroying`으로 `Destroying`을 연결하고 `Ref` dep 콜백을
(재)등록, 후자는 그 대칭). 여기 원래 *"둘만 하고"*라고 적혀 있었는데
이 분기와 직접 모순이었다. **이게 `Effect`의 leaf 사망 cleanup을 실제로
**⭐ [2026-08-24 6라운드 `H-11`, 2026-08-25 7라운드 `H-58`로 축소]
그리고 `isEffect(value)`면 분기가 하나 더 붙는다** —
`value:_bindDestroying(inst)` / `:_unbindDestroying()`를 부른다.
여기 원래 *"둘만 하고"*라고 적혀 있었는데 이 분기와 직접 모순이었다.
**⚠️ [2026-08-25 정정] `handle._observers` 전부로 cascade하지
않는다** — 그 필드 자체가 폐기됐다(`_deps` 하나로 통합). 그리고
`_bindDestroying`**`Ref` dep 콜백을 (재)등록하지 않는다** —
dep 등록은 생성자에서 끝나고, 여기서 하는 건 `Destroying` 연결과
**조건부 캐치업 한 줄**(`if not self._installed or
self._epochs:Refresh() then self:Rerun() end`)뿐이다. **이게 `Effect`의 leaf 사망 cleanup을 실제로
발화시키는 유일한 배선**이고, M6의 `_detached` 정리가 여기 의존한다
(그 항목의 `H-50` 각주 참고). 의사코드는 `base/lifecycle-pattern.md`
`base/effect-plan.md`가 소스. **[2026-08-14

91
scripts/relink.sh Executable file
View file

@ -0,0 +1,91 @@
#!/usr/bin/env bash
# pesde 워크스페이스 링크를 **실제 복사**로 교체한다. 여러 번 돌려도 최신을 따라간다.
#
# 왜 필요한가: **`luau` CLI가 심볼릭 링크를 못 탄다.** 디렉토리 링크도 파일
# 링크도 안 되고, `luau-analyze`는 더 나쁘게 **조용히 통과**한다(모듈을 `any`로
# 떨어뜨리고 진단 0건 — "거짓 클린"). pesde의 워크스페이스 링크는 전부 디렉토리
# 심볼릭이라, `quad-base/src/init.luau`의 `require("./roblox_packages/quad_types")`가
# 그 링크에 닿는 순간 죽는다. 근거와 최소 재현은
# `.claude/qa-request/pre-implementation-handtrace-round7-followup.md`의 🅛 절.
#
# ⚠️ 이 스크립트가 지켜야 하는 것 셋(전부 `/code-review high`가 실제로 잡은 것):
# 1. **`cp -rL`** — 평범한 `cp -r`는 심볼릭을 **심볼릭 그대로** 복사한다.
# 중첩 패키지(quad_types 안의 type_version_check 링크)가 그대로 살아남아
# 같은 "거짓 클린"이 재발한다.
# 2. **스킵은 치명적으로** — 원본이 없어 건너뛴 채 성공으로 끝나면 테스트가
# 얼어붙은 스냅샷을 돈다. 이 스크립트가 막으려던 실패 모드 그 자체다.
# 3. **깊이 고정 글롭** — `find -path '*/.pesde/...'`의 `*`는 `/`를 넘어가
# 중첩 `.pesde`와 `src`까지 잡는다. 셸 글롭은 `/`를 안 넘으므로 그걸 쓴다.
set -euo pipefail
shopt -s nullglob
cd "$(dirname "$0")/.."
MANIFEST=".relink-manifest"
fail=0
note_fail() { echo "relink: $*" >&2; fail=1; }
# 0) 매니페스트가 없는데 이미 복사돼 있으면(도입 전에 한 번 돌린 트리 등)
# pesde 레이아웃에서 원본 매핑을 되살린다.
# 구조: <member>/{luau,roblox}_packages/.pesde/<owner>+<pkg>/<ver>/<pkg>/<entry>
if [ ! -f "$MANIFEST" ]; then
for d in */luau_packages/.pesde/*/*/* */roblox_packages/.pesde/*/*/*; do
[ -d "$d" ] || continue
pkg="$(basename "$d")"
member="${pkg//_/-}"
[ -d "$member" ] || continue
for e in "$d"/*; do
n="$(basename "$e")"
[ -e "$member/$n" ] || continue
printf '%s\t%s\n' "$e" "$(readlink -f "$member/$n")" >> "$MANIFEST"
done
done
fi
replaced=0
refreshed=0
# 1) 아직 심볼릭인 것 — 원본을 기록하고 복사로 교체. 중첩 링크가 남지 않도록
# `cp -rL`로 역참조하고, 남은 게 없을 때까지 반복한다.
while :; do
found=0
while IFS= read -r link; do
found=1
target="$(readlink -f "$link")"
if [ ! -e "$target" ]; then note_fail "dangling symlink: $link"; continue; fi
printf '%s\t%s\n' "$link" "$target" >> "$MANIFEST"
rm "$link"
cp -rL "$target" "$link"
replaced=$((replaced + 1))
done < <(find . -path ./.git -prune -o -type l -print | grep '/\.pesde/' || true)
[ "$found" -eq 0 ] && break
done
# 2) 이미 복사된 것 — 매니페스트를 보고 원본에서 다시 복사(워크스페이스 멤버를
# 고쳤을 때 테스트가 옛 스냅샷을 돌지 않게).
if [ -f "$MANIFEST" ]; then
sort -u "$MANIFEST" -o "$MANIFEST"
while IFS=$'\t' read -r link target; do
[ -n "$link" ] || continue
[ -L "$link" ] && continue # 방금 1)에서 처리됨
if [ ! -e "$target" ]; then note_fail "missing source: $target (for $link)"; continue; fi
parent="$(dirname "$link")"
if [ ! -d "$parent" ]; then note_fail "missing link parent: $parent — 매니페스트가 낡았다, $MANIFEST 를 지우고 pesde install 후 다시 돌릴 것"; continue; fi
rm -rf "$link"
cp -rL "$target" "$link"
refreshed=$((refreshed + 1))
done < "$MANIFEST"
fi
echo "relink: $replaced symlink(s) replaced, $refreshed copy/copies refreshed"
if [ "$fail" -ne 0 ]; then
echo "relink: 위 스킵 때문에 트리가 최신이 아니다 — 테스트를 신뢰하지 말 것" >&2
exit 1
fi
# 3) 최종 확인 — .pesde 아래에 심볼릭이 하나도 남으면 안 된다.
leftover="$(find . -path ./.git -prune -o -type l -print | grep '/\.pesde/' || true)"
if [ -n "$leftover" ]; then
echo "relink: 심볼릭이 남았다 (luau가 못 탄다):" >&2
echo "$leftover" >&2
exit 1
fi

17
scripts/test.sh Executable file
View file

@ -0,0 +1,17 @@
#!/usr/bin/env bash
# 스모크 테스트 — 리링크를 먼저 돌린다(scripts/relink.sh 주석 참고).
set -euo pipefail
shopt -s nullglob
cd "$(dirname "$0")/.."
./scripts/relink.sh
files=(quad-base/test/smoke.*.luau)
if [ "${#files[@]}" -eq 0 ]; then
echo "no smoke tests found (quad-base/test/smoke.*.luau)" >&2
exit 1
fi
fail=0
for f in "${files[@]}"; do
echo "=== $f"
luau "$f" || fail=1
done
exit "$fail"