diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 7706e07..7d4718d 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -515,6 +515,13 @@ Connect가 도는 숨은 churn 비용도 있음(Store Set은 dedup 안 함, lazy State 핸들로 통일, 아래 "Store/State/Source 온톨로지" 절의 "`:With`/ `:Compute`" 부분 참고). +**`fn`을 커링 스타일로 짜는 것도 권장(2026-08-07 일곱 번째 세션)** — +`key:Compute(makeFormatter("ko-KR"))`처럼 팩토리가 실제 `fn`을 만들어 +반환하는 패턴, Observer/Effect의 동일 관용구(아래 "`fn`을 커링 스타일로 +짜는 것도 모듈화 관용구로 권장" 절, `base/effect-plan.md`)와 같은 결 — +`:Compute`가 원래부터 이 셋 중 제일 먼저 있던 자리라 뒤늦게 문서화된 +것뿐, 새 결정이라기보다 이미 있던 패턴을 명문화한 것. + ### `:Compute(fn)`의 선택적 두 번째 인자 — `previous` (무거운 파생 객체 재사용, 2026-08-06) **배경**: `:Compute`의 결과가 그 자체로 무겁고 재생성 비용이 큰 엔진 @@ -630,14 +637,56 @@ retract/Destroy되면 자동으로 정리됨. 이 State가 계속 재계산되게만 강제하고 싶을 때 씀. 문서화만 확실히 하면 별문제 없음(사용자 판단). -### 백로그(미확정) — `state:Apply(...)`: `:With`+`:Compute` 등록을 커링으로 자동화 (2026-08-07 여섯 번째 세션, 사용자 제안) +### `state:Apply(factory)` — Modifier와 동일한 순수 체이닝 설탕으로 확정 (2026-08-07 일곱 번째 세션) -Effect/Observer의 `fn` 커링 관용구 논의 중 나온 인접 아이디어 — `Modifier`의 -`:Apply(factory)`(팩토리 체이닝, `modifier-plan.md` 8번)와 비슷하게, 여러 -개를 커링으로 받아 알아서 `:With`/`:Compute` 등록을 대신 해주는 -`state:Apply(...)` 같은 조합기가 있으면 편리할 수 있다는 제안. **지금 결정 -필요 없음 — 백로그로만 기록.** 구체 시그니처/필요성 검증 없음, 나중에 -`:With`/`:Compute` 관용구가 실제로 자주 반복되는 게 확인되면 다시 논의. +**처음 제안됐던 "`:With`/`:Compute` 등록을 커링으로 자동화하는 조합기" +방향은 기각됨 — 사용자가 재확인한 실제 의도는 그보다 훨씬 단순함.** +`Modifier:Apply(factory)`도 매번 새 값을 만들어내는 체이닝 설탕일 뿐이듯, +State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양이라 — +`state:Apply(factory)`는 그냥 `factory(state)`를 메소드 체이닝 문법으로 +쓴 것뿐이고 그 이상의 계약은 없음(`Modifier:Apply`와 완전히 동일한 +정의: `function(self, factory) return factory(self) end`). + +- **동기**: 커링 팩토리 두 개 이상을 이미 있는 문법만으로 이으면 바깥에서 + 안으로 겹쳐 읽어야 하는 중첩 호출이 됨 — 실제 형태로 예를 들면, + ```lua + -- Apply 없이: 안쪽(가장 최근에 만든 것)부터 거꾸로 읽어야 함 + local capped = capAt(100)(withLocale(localeStore.locale)(rawScore)) + + -- state:Apply로: 왼쪽에서 오른쪽, 만든 순서 그대로 읽힘 + local capped = rawScore + :Apply(withLocale(localeStore.locale)) + :Apply(capAt(100)) + ``` + 팩토리가 세 개, 네 개로 늘어날수록 앞쪽 버전은 괄호 깊이와 읽는 방향이 + 코드 작성 순서와 반대로 꼬여 diff/리뷰에서 특히 안 좋음 — `:Apply` + 버전은 각 줄이 "그다음 뭘 했는지"를 순서대로 나열하므로 Modifier + 체이닝(`mod:FontSize(14):Apply(Boldify(10)):Apply(Italicify)`)과 읽는 + 방식이 완전히 통일됨. `:With`/`:Compute` 자체를 대신 호출해주는 + 자동화가 아니므로, 여전히 팩토리 본문 안에서 `:With`/`:Compute`를 + 직접 호출하는 건 팩토리 작성자 몫. +- **구현 비용 거의 0**: Modifier와 달리 State/Source는 제네릭 `__index`로 + 필드 setter를 즉석 합성하는 메커니즘이 없어서(고정된 메소드 표면만 + 존재), Modifier의 `Apply`처럼 "필드 이름으로 예약해야 하는" 충돌 + 자체가 없음 — 그냥 고정 메소드 하나 추가하는 것. +- **타입은 `factory: (State) -> U): U`로 완전히 열어둠** — Modifier의 + `Apply`는 `factory: (M) -> M`으로 같은 타입을 유지해야 체이닝이 + 이어지지만, State의 `:Apply`는 팩토리가 State가 아닌 값(예: 최종 + 요약된 plain 값)을 반환해 반응형 그래프를 벗어나는 탈출구로 쓰는 것도 + 막을 이유가 없음 — Modifier보다 오히려 더 자유로운 시그니처. +- **Source도 자동 포함**: Source가 State를 구조적으로 만족하는 기존 + 델리게이션(`__index`로 `:With`/`:Compute` 위임)에 `:Apply`도 그대로 + 얹히므로 별도 구현 불필요. +- **Effect/Observer/Compute의 `fn` 커링 권장(위 절들)과 같은 스레드지만 + 별개 기능** — 커링은 "`fn` 자체를 팩토리로 짜는 관용구" 권장이고, + `:Apply`는 그렇게 만든 팩토리를 체이닝 문법으로 적용하는 수단. 둘이 + 합쳐지면 `state:Apply(makeFormatter("ko-KR"))`처럼 자연스럽게 이어짐. + +**Observer/Effect의 `:Subscribe()`/`:Unsubscribe()`는 이 절과 무관한 +별개 주제** — 아래 새 절로 분리(이전에 이 헤더 아래 잘못 걸려 있던 +문서 버그 수정, 내용 자체는 이미 확정된 것 그대로). + +### Observer의 `:Subscribe()`/`:Unsubscribe()` — children 배열 밖 독립 구독 (2026-08-06 후속 세션) **문제**: children 배열에 넣는 자동 라이프사이클 바인딩은 Observer가 "어딘가 leaf에 붙어있다"는 걸 전제함. 근데 흔한 실사용 패턴 하나가 이 @@ -692,6 +741,50 @@ Effect/Observer의 `fn` 커링 관용구 논의 중 나온 인접 아이디어 객체를 mutate하고 그대로 돌려주는 것)지만 표면 문법은 비슷하게 체이닝 가능. +### 이중 바인딩 금지 — leaf 부착과 `:Subscribe()`는 상호 배타적, `Bound` 플래그로 즉시 에러 (2026-08-07 일곱 번째 세션) + +**규칙**: 같은 Observer/Effect 핸들 하나는 라이프사이클 바인딩 경로를 +딱 하나만 가질 수 있음 — children 배열에 놓여 leaf에 자동 부착되거나 +(위 weak table 경로) `:Subscribe()`로 수동 등록되거나(위 강참조 +레지스트리 경로), 둘 중 하나만. **둘 다 동시에 걸리는 건 UB로 확정** — +이미 leaf에 부착된 핸들을 다시 `:Subscribe()`하는 것도, 이미 +`:Subscribe()`한 핸들을 children 배열에 놓아 leaf로도 부착시키는 것도 +둘 다 금지. + +**UB를 조용한 오동작이 아니라 즉시 에러로 만든다** — 판별 비용이 사실상 +0(불리언 필드 하나 확인)이라, 조용히 이상하게 동작하게 두는 것보다 +바로 에러를 던져 버그를 그 자리에서 잡는 게 엔지니어링상 훨씬 쌈: + +```lua +-- :Subscribe() 진입부, children 배열 leaf 부착부 — 둘 다 진입 전 동일하게 확인 +if self.Bound then + error("Observer/Effect가 이미 다른 경로로 바인딩됨 — leaf 부착과 :Subscribe()는 동시에 쓸 수 없음") +end +self.Bound = true +``` + +- **`Bound`는 가칭** — 용어 정리 라운드에서 최종 이름 재검토 대상 + (`.claude/question.md`에 반영). +- 이 플래그는 어느 경로가 먼저 왔는지와 무관하게 "이미 바인딩됨"만 + 표시 — 두 진입점이 똑같이 확인/설정하므로 순서와 무관하게 대칭적으로 + 막힘. +- **`:Unsubscribe()`는 여전히 "어떤 경로로 바인딩됐든 그 계약을 끊는다"는 + 뜻으로 통일** — `Bound`가 어느 경로로 세워졌는지와 무관하게, + `:Unsubscribe()` 한 번으로 그 바인딩(leaf의 Destroying 연결이든 수동 + 강참조 등록이든)을 끝내고 최종 정리를 수행. 위 "`:Unsubscribe()`는 + 자동(리프) 케이스에도 동일하게 씀" 절과 정합 — 이중 바인딩 금지 규칙과 + 별개로, "단일 바인딩을 끊는" `:Unsubscribe()` 자체의 계약은 안 바뀜. +- **Effect도 동일 규칙 적용** — 내부적으로 Observer를 조합하는 경우든 + `state` 없는 경우든 같은 `Bound` 게이트를 그대로 재사용 + (`base/effect-plan.md`). 이전에 그 문서에 적어뒀던 "leaf 부착과 + `:Subscribe()`를 동시에 쓰는 것도 안전"이라는 서술은 **이 규칙으로 + 대체(정정)** — 안전하게 지원하는 게 아니라 애초에 막아야 하는 + 조합이었음. +- **문서화 경고 대상(api/심화)**: "한 Effect/Observer 핸들을 children + 배열에 놓았다면 그걸 다시 `:Subscribe()`하지 말 것, 반대도 마찬가지 — + 두 경로를 동시에 쓰고 싶으면 각각 독립된 새 `Effect(...)`/ + `state:Observer(...)` 호출로 따로 만들 것"을 명시할 것. + ## Unix 파이프에서 영감 받은 스트림 지향 — 원래 의도, 해소됨 **배경**: quad는 원래 이 파이프라인/스트림 개념에서 영감을 받아 만들어짐. diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md index 3bf439f..4ccecfc 100644 --- a/.claude/base/effect-plan.md +++ b/.claude/base/effect-plan.md @@ -69,6 +69,52 @@ end; lastConn = ... end)`) **Observer 자체**가 이걸 대신해줄 이유는 (Effect)으로 분리해 얹었을 뿐, Observer의 기본 계약(재실행 신호만, cleanup은 클로저로 직접)은 그대로 가볍게 유지됨. +## `EffectHandle:Subscribe()`/`:Unsubscribe()` — leaf 없이 쓰는 독립 Effect (2026-08-07 일곱 번째 세션) + +**동기**: 지금까지 Effect의 유일한 생애주기 경로는 children 배열의 leaf +부착뿐이었음 — leaf 없이 `Effect(fn)`/`Effect(fn, state)`를 호출하면 +설치(1회 실행)는 되지만 반환된 `EffectHandle`엔 아무 인터페이스도 없어서 +cleanup을 트리거할 방법이 없는 막다른 길이었음. `state:Observer(fn)`가 +이미 `:Subscribe()`/`:Unsubscribe()`(위 bind-system-plan.md 절)로 "children +배열 밖, 모듈/스크립트 레벨에서 독립적으로 켜고 끄는" 경로를 갖고 있는데, +Effect도 모듈/스크립트 사이드 이펙트(백그라운드 시스템, non-UI 코드가 +quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로 쓰일 수 있어서 +같은 결로 필요 — `Effect`도 leaf 없이 독립적으로 켜고 끌 수 있어야 함. + +**확정**: `EffectHandle`에도 `:Subscribe()`/`:Unsubscribe()` 추가, 둘 다 +`self` 반환(Observer와 동일한 fluent 대칭). + +- **`:Subscribe()`** — Observer가 쓰는 것과 같은 강참조 레지스트리에 + 자신(또는 `state` 있는 경우 내부 Observer)을 등록 — 새 메커니즘 아님, + 기존 레지스트리 재사용. 이후 로컬 변수로 참조를 안 들고 있어도 계속 + 살아있음(Observer와 동일 관용구). +- **`:Unsubscribe()`는 Observer의 것을 그냥 위임하지 않는다 — Effect + 계층에서 의미가 확장됨.** Observer의 `:Unsubscribe()`는 "미래 재실행만 + 끊는다"(Observer 자체엔 정리할 상태가 없음)로 충분하지만, Effect의 + 계약은 "생애주기가 끝나는 시점에 마지막 cleanup이 정확히 1회 호출된다" + 이고 leaf 사망은 그 "끝"의 신호 중 하나일 뿐이라, `:Unsubscribe()`도 + 동일하게 "지금 끝났다"는 신호로 취급해야 계약이 일관됨: + 1. `state`가 있으면 내부 Observer도 `:Unsubscribe()`해서 향후 재실행을 + 끊고, + 2. **직전(또는 유일한) cleanup을 정확히 1회 호출** — leaf가 죽을 때 + 하던 것과 정확히 같은 이벤트를 수동으로 앞당기는 것. + 3. **idempotent, 그리고 이후 leaf가 실제로 죽어도 cleanup이 중복 + 호출되면 안 됨** — 새 메커니즘 불필요, Observer가 이미 확정해둔 + "`Subscribed` 필드 우선 liveness 체크"가 자동(리프)/수동(Unsubscribe) + 두 경로를 하나의 게이트로 OR 묶어주므로 여기 그대로 얹힘. +- **`state` 없는 mount-only Effect엔 특별한 분기 불필요** — install은 이미 + `Effect(fn)` 호출 시점에 끝나 있으므로, `:Unsubscribe()`는 그냥 "지금 + leaf-사망 cleanup을 수동으로 트리거"하는 것과 완전히 동치. +- **leaf 부착과 `:Subscribe()`를 동시에 쓰는 건 UB — 정정(2026-08-07 + 일곱 번째 세션 후속)**: 처음엔 "같은 liveness 게이트를 공유하니 + 동시에 써도 안전"으로 적었으나, 애초에 한 핸들은 라이프사이클 바인딩 + 경로를 하나만 가져야 한다는 게 맞는 방향이라 판단이 뒤집힘 — 상세 + 규칙과 `Bound` 플래그 기반 즉시-에러 메커니즘은 + `base/bind-system-plan.md`의 "이중 바인딩 금지" 절 참고. leaf 부착 + **후** `:Unsubscribe()`로 조기 해제하는 것(위 "Observer의 + `:Unsubscribe()`는 자동 케이스에도 동일하게 씀" 패턴)은 여전히 정상 — + 금지되는 건 leaf 부착과 `:Subscribe()`를 **같이** 쓰는 것뿐. + ## 해결됨 — Effect/Observer 관계 (2026-08-07 여섯 번째 세션, 이전 미해결 절 대체) **과거 미해결이었던 두 질문 모두 확정**: diff --git a/.claude/question.md b/.claude/question.md index fb56a81..fd416d9 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -87,6 +87,10 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** Source 판별 predicate 세 개의 이름 — 동작은 전부 확정(`base/ modifier-plan.md` 9번, `base/bind-system-plan.md`의 `isState` 절), 이름만 다른 가칭들과 같이 용어 정리 라운드에서 재검토. +- **`Bound`(3순위, 사소함, 2026-08-07 일곱 번째 세션 추가)**: Observer/ + Effect 핸들이 leaf 부착과 `:Subscribe()` 중 이미 어느 한쪽으로 + 바인딩됐는지 표시하는 내부 플래그 이름(`base/bind-system-plan.md` + "이중 바인딩 금지" 절) — 동작은 확정, 이름만 가칭. - **"프로바이더"(3순위, 사소함)**: `base/module-lifecycle-plan.md`가 "provider"라고 불러온, `isHandlable`로 참여 여부를 결정하고 우선순위대로 스캔되는 pluggable 참가자 개념 — 정확한 이름을 "provider"/"processor"/ diff --git a/CLAUDE.md b/CLAUDE.md index 164e569..2ea1303 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -930,9 +930,64 @@ setter를 단발로 직접 호출하는 흔한 경로는 여전히 mutable이라 - **백로그로만 기록, 결정 안 함**: `state:Apply(...)`처럼 여러 개를 커링으로 받아 `:With`/`:Compute` 등록을 자동화하는 조합기 아이디어(사용자 제안, `Modifier:Apply`의 State판 대응물) — `base/bind-system-plan.md`에 백로그 - 절로만 남김, 시그니처/필요성 미검증. + 절로만 남김, 시그니처/필요성 미검증. **(2026-08-07 일곱 번째 세션에서 + 이 방향 자체가 기각되고 훨씬 단순한 형태로 확정됨 — 아래 참고.)** - 이걸로 `question.md` 0번(추가 프리미티브 논의)의 열린 항목은 "키 기반 동적 컬렉션 재조정" 하나만 남음. **다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 이미 설계된 것의 세부 마무리라 M0 착수 우선순위 자체는 그대로. + +## 2026-08-07 일곱 번째 세션 — `:Compute` 커링, `state:Apply` 확정(백로그안 기각), Effect `:Subscribe`/`:Unsubscribe` 신설, 이중 바인딩 금지 + +짧은 대화형 세션, 네 가지를 순서대로 처리 — 전부 `base/bind-system-plan.md`/ +`base/effect-plan.md`/`ROADMAP.md`/`question.md`에 반영 완료: + +1. **`:Compute(fn)`에도 커링 권장 노트 추가.** 여섯 번째 세션에서 Observer/ + Effect의 `fn`에만 문서화됐던 "팩토리가 실제 `fn`을 만들어 반환하는 + 커링 스타일 권장"이 `:Compute`엔 빠져 있었음 — 같은 결이라 자연스럽게 + 확장, `bind-system-plan.md` "`:With`+`:Compute`" 절에 추가. +2. **`state:Apply(factory)` 확정 — 원래 백로그였던 "`:With`/`:Compute` + 등록을 커링으로 자동화하는 조합기" 방향은 기각.** 사용자가 재확인한 + 실제 의도는 훨씬 단순함: `Modifier:Apply`와 똑같이 `factory(self)`를 + 체이닝 문법으로 부르는 순수 설탕(`function(self, factory) return + factory(self) end`) — `fnb(c,d)(fn(a,b)(state))`처럼 팩토리를 안에서 + 밖으로 겹쳐 읽어야 하는 중첩을 `state:With(a,b):Compute(fn(a,b)) + :Apply(fnb(c,d))`로 펴는 게 유일한 목적. 구현 비용 거의 0(State는 + Modifier와 달리 제네릭 `__index` 필드 setter 합성이 없어 이름 예약 + 충돌도 없음), 타입은 `factory: (State) -> U): U`로 Modifier보다 + 더 열어둠(팩토리가 State 밖 plain 값을 반환해 반응형 그래프를 벗어나는 + 것도 허용). Source는 기존 `:With`/`:Compute` 델리게이션에 얹혀 자동 + 포함. `bind-system-plan.md` "`state:Apply(factory)`" 절, 구체 전/후 + 코드 예시까지 반영. 부수적으로 같은 헤더 아래 잘못 걸려 있던 Observer + `:Subscribe`/`:Unsubscribe` 내용(무관한 주제)을 별도 절로 분리하는 + 문서 버그도 수정. +3. **`EffectHandle:Subscribe()`/`:Unsubscribe()` 신설.** 지금까지 Effect의 + 유일한 생애주기 경로는 children 배열 leaf 부착뿐이라, leaf 없이 쓰는 + 모듈/스크립트 레벨 사이드 이펙트(백그라운드 시스템 등)엔 반환된 + `EffectHandle`이 막다른 길이었음 — Observer가 이미 가진 `:Subscribe`/ + `:Unsubscribe`와 같은 결로 확정. **핵심 주의점**: Effect의 + `:Unsubscribe()`는 Observer의 것을 그냥 위임하면 안 됨 — Observer의 + 계약은 "미래 재실행만 끊는다"로 충분하지만, Effect의 계약은 "생애주기가 + 끝나는 시점에 마지막 cleanup이 정확히 1회 호출된다"이고 leaf 사망은 + 그 "끝"의 신호 중 하나일 뿐이라, `:Unsubscribe()`도 동일하게 "지금 + 끝났다"는 신호로 취급해 마지막 cleanup을 트리거해야 계약이 일관됨(leaf + 가 살아있어도 마찬가지). idempotent 보장은 기존 `Subscribed` 필드 + liveness 체크 재사용으로 공짜. `base/effect-plan.md` 신규 절. +4. **Observer/Effect 이중 바인딩 금지 — `Bound`(가칭) 플래그로 즉시 + `error`.** 처음엔 "leaf 부착과 `:Subscribe()`를 동시에 써도 같은 + liveness 게이트를 공유하니 안전"이라고 적었으나, 사용자가 애초에 한 + 핸들은 라이프사이클 바인딩 경로를 하나만 가져야 한다고 정정 — 동시 + 바인딩은 UB로 확정하되, 판별 비용이 사실상 0(불리언 필드 하나)이라 + 조용한 오동작 대신 그 자리에서 `error`를 던지는 쪽으로 결정 + (엔지니어링 비용 대비 디버깅 이득이 명확). 두 진입점(`:Subscribe()` + 호출부, children 배열 leaf 부착부)이 똑같이 확인/설정하는 대칭적 게이트 + — 순서 무관. `bind-system-plan.md` "이중 바인딩 금지" 절 신설, + `effect-plan.md`의 3번 항목 서술은 이 규칙으로 대체(정정 표시 남김). + +**부수 정리**: `ROADMAP.md` M3에 `state:Apply`/Effect `:Subscribe`· +`:Unsubscribe`/이중 바인딩 금지 체크박스 추가. `question.md`에 `Bound` +이름을 용어 정리 대상(3순위)으로 추가. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 +설계 확정이라 M0 착수 우선순위 자체는 그대로. diff --git a/ROADMAP.md b/ROADMAP.md index bea811c..216543f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -67,13 +67,23 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] `Blocker.luau`(`base/blocker-plan.md` 참고 — 여러 Source를 한꺼번에 바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive, State와 밀접히 연관돼 있어 같은 마일스톤에서 개발) +- [ ] `state:Apply(factory)`(`base/bind-system-plan.md` "`state:Apply(factory)`" + 절, 2026-08-07 일곱 번째 세션) — `factory(self)`를 체이닝 문법으로 + 부르는 순수 설탕, `factory: (State) -> U): U`로 열린 타입. Source도 + 기존 `:With`/`:Compute` 델리게이션에 얹혀 자동 포함 - [ ] `state:Observer(fn)` — children 배열 leaf 참가자, **등록 즉시 1회 실행 확정**(`base/bind-system-plan.md`의 Observer 절), `isObserver` 판별자, canExecute 게이팅, `:Subscribe()`/`:Unsubscribe()` - [ ] `Effect(fn, state?)`(`base/effect-plan.md`) — `state` 생략 시 설치 1회+leaf 사망 시 확정 정리, `state` 지정 시 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React - `useEffect` 동형). Observer 구현 이후에 착수(의존 관계) + `useEffect` 동형). Observer 구현 이후에 착수(의존 관계). + `EffectHandle:Subscribe()`/`:Unsubscribe()`도 추가(leaf 없이 쓰는 + 모듈/스크립트 레벨 Effect) — `:Unsubscribe()`는 Observer와 달리 + 마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션) +- [ ] Observer/Effect 이중 바인딩 금지 — `Bound`(가칭) 플래그로 leaf 부착과 + `:Subscribe()`가 동시에 걸리면 즉시 `error`(`base/bind-system-plan.md` + "이중 바인딩 금지" 절, 2026-08-07 일곱 번째 세션) - [ ] mock 대상 테스트 ## M4 — 첫 end-to-end 반응형 업데이트