quad/.claude/archive/canexecute-inst-arg-reversed.md
qwreey e35905ceca
docs: 코퍼스 전체 감사 + canBound/canExecute 재분리, Ref 0-W 해소
전체 .claude/ 코퍼스를 doc-check.py + 6개 병렬 서브에이전트로 감사해
stale 세션 번호, 잘못된 인용, 자기모순 배너 등 15개 파일의 실제 사실
오류를 발견·수정. 이어서 question.md 0-W(같은 Ref 객체가 두 자리에
동시에 놓이는 문제)를 선택지 (a)로 확정 — RefLeafHandler가 새 Relate
없이 bindLifetime/unbindLifetime을 재사용해 이중 배치를 방지.

이 과정에서 bindLifetime의 이중 바인딩 가드(bound 문맥)와 State emit
전파 게이팅(execute 문맥)이 서로 다른 질문인데 canExecute 하나로
뭉쳐 있던 걸 발견 — canBound를 별도 진입점으로 재도입(판정 로직은
비공개 헬퍼 하나를 공유, 코드 중복 없음). Tag/Attribute의 미지원
백엔드 처리 모델도 여러 라운드 논의 끝에 확정: TagHandler 등은
quad-base가 스스로 등록하고, addTag/removeTag/setAttribute만 백엔드
팩토리가 채우는 타입 계약 — 안 채운 슬롯은 명시적으로 에러내는 스텁.

/code-review high가 추가로 3건(gcconn-trick-verification.md 배너,
README.md 인덱스 4곳, luau-test/STATUS.md의 재작성 지침) 발견해 정정.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXqrGrh83Sh61C3tUCMdmY
2026-08-14 11:03:24 +09:00

5.9 KiB

[역전됨] canExecute(inst, value) 2-인자 + bindLifetime.Subscribed를 세팅 — 둘 다 오염, canExecute(value) 1-인자로 정정

역전 일시: 2026-08-14 (다섯 번째 세션). 원 확정 일시: 2026-08-08 (다섯 번째 세션, "재정정"이라는 이름으로 들어옴) ~ 2026-08-09 (여섯 번째 세션, canBound가 이 전제 위에 세워짐). 현재 유효한 설계: base/lifecycle-pattern.md의 "bindLifetime/canExecute/unbindLifetime" 절이 최종 소스.

역전된 사례 — 원래 무엇을 확정했었나

bindLifetime(inst: any, value: any): ()
unbindLifetime(inst: any, value: any): ()
canExecute(inst: any, value: any): boolean

그리고 bindLifetime 구현 스케치가 이랬음:

function bindLifetime(inst, value)
    ...
    gchold[value] = true
    if isOE then value.Subscribed = true end -- canExecute가 보는 필드 그대로 재사용
end

function canExecute(inst, value)
    if (isObserver(value) or isEffect(value)) and not value.Subscribed then
        return false
    end
    local gcconn = relate:GetStrong(inst, GCCONN)
    return gcconn ~= nil and gcconn.Connected
end

당시 명시된 근거(2026-08-08 세션 원문): "Observer 자신의 바인딩 생존(Subscribed)과 inst 자체 생존(gcconn)은 독립적인 두 조건이라 하나의 opaque handle로 뭉치면 'inst는 살아있지만 이 Observer는 이미 :Unsubscribe()됨' 케이스를 못 구별함."

여기에 2026-08-09 여섯 번째 세션이 한 겹 더 얹어, bind-system-plan.mdcanBound(handle) 절에 **"이 내부 플래그는 새 필드가 아니라 canExecute가 이미 보는 .Subscribed 필드 그 자체"**라고 못박고, bindLifetime/ unbindLifetime이 이 필드를 세팅/해제하는 것으로 확정했음.

왜 틀렸나

.Subscribed는 전역 :Subscribe() 경로 전용 필드이고, bindLifetime과는 일절 이해관계가 없다(사용자가 여러 차례 명시해온 내용). 위 설계는 이 필드에 "leaf 바인딩도 살아있음"이라는 두 번째 의미를 억지로 겹쳐 얹었고, 그 순간 leaf 경로의 생존을 value에게 물을 방법이 사라져서 남은 유일한 경로가 "inst의 gcconn을 조회한다"가 됐음 — 2-인자 시그니처는 그 오염의 증상이지 원인이 아니었음.

정확한 분해는 이것:

묻는 것 근거 value만으로 가능?
전역으로 등록됐나 value.Subscribed 필드 O
묶인 inst가 살아있나 bindLifetimevalue 쪽 릴레이션에 복사해둔 gcconn O

즉 두 조건이 "독립적"이라는 관찰 자체는 맞았지만, 그로부터 "inst를 인자로 받아야 한다"는 결론이 안 나옴 — bindLifetime이 바인딩 시점에 gcconn 참조를 value 쪽으로 복사해두면 둘 다 value 하나로 물을 수 있음. 실제로 2026-08-07 시점의 더 오래된 초안(bind-system-plan.md:Subscribe() 절)에 이미 올바른 모양이 스케치돼 있었음:

if self.Subscribed then return true end
if self.Connection then return self.Connection.Connected end

2026-08-08의 "재정정"은 이 초안을 개선한 게 아니라 되돌린 것이었음.

놓친 신호 — 호출부가 코드로 한 번도 안 나왔다

이 오류가 여섯 세션 넘게 살아남은 이유는 canExecute의 실제 호출부가 어느 문서에도 코드로 등장한 적이 없기 때문. bind-system-plan.md/ source-state-plan.md(당시 store-semantics.md)/slot-plan.md는 전부 "발화 시 canExecute로 게이팅됨" 같은 서술만 하고 넘어갔고, dispatch-core-plan.md는 아예 "핸들러가 직접 canExecute를 재구현할 필요 없음 — Observer가 이미 자기 Subscribed 상태로 게이팅됨"이라고 적어 호출부를 없는 것처럼 만들었음.

실제 호출부는 State의 전파 루프인데, 거기엔 inst가 없고 있어서도 안 됨 (State는 자기가 어느 Instance에 걸렸는지 모르는 게 정상 — 여러 곳에 걸릴 수 있음). 즉 2-인자 시그니처는 진짜 호출부에서 호출 자체가 불가능했고, 아무도 그 코드를 써보지 않아서 드러나지 않았을 뿐임.

일반 교훈: 계약(시그니처)을 정할 때 호출부를 최소 하나는 의사코드로 같이 적어둘 것. "어디선가 게이팅됨"이라는 서술은 검증이 안 되는 문장이고, 실제로 이 코퍼스에서 여섯 세션을 살아남았음. .claude/tools/doc-check.py가 잡을 수 있는 종류가 아니므로(문서 참조는 전부 정상이었음) 사람/에이전트 감사 체크리스트 쪽에 남김.

같이 폐기된 것

  • canBound(handle) — 이 오염된 .Subscribed 재사용 위에 세워진 predicate라 정의 자체가 성립 안 함. canExecute(value) 하나로 통합. [부분 되짚음, 2026-08-14 열한 번째 세션] 이 "하나로 합친다"는 판단만 나중에 다시 갈라짐(이 문서가 고친 시그니처/오염 원인 정정은 안 바뀜) — canBound가 별도 진입점으로 재도입되어 bindLifetime/ Observer:Subscribe()의 "이미 묶여 있는가"(bound 문맥) 가드를 맡고, canExecute는 State emit 전파 루프의 "지금 발화해도 되는가"(execute 문맥) 게이팅에만 씀 — 판정 로직(isBoundAlive)은 여전히 공유, 이름만 문맥별로 분리. 상세는 base/lifecycle-pattern.md의 "canBound vs canExecute" 절, 계기는 Ref 이중 배치 방지(question.md 0-W, base/ref-plan.md "이중 배치 방지" 절).
  • gcconn/gchold의 lazy 생성bindLifetime 첫 호출에서 만들던 것을 Instance 생성 시점으로 올림. 이유는 이 역전과 별개(Instance userdata 포인터 동일성 — inst-키 Relate 전체의 전제), 같은 세션에 확정돼 같은 절에 반영됨.