quad/.claude/session/2026-08-14-04-processedpreref-postref-symmetry.md
qwreey af513aeb84
fix(lifecycle): canExecute/unbindLifetime을 value 1-인자로 정정, Subscribed 오염 제거
canExecute(inst,value) 2-인자 시그니처를 폐기하고 canExecute(value)로 정정.
2-인자는 증상이었고 원인은 2026-08-08 세션이 .Subscribed에 "leaf 바인딩
생존"이라는 두 번째 의미를 겹쳐 얹은 것 — .Subscribed는 전역 :Subscribe()
전용 필드이고 bindLifetime과 무관함. bindLifetime이 바인딩 시점에 inst의
gcconn 참조를 value 쪽 Relate로 복사해두면 생존을 value 하나로 물을 수 있음.

- canBound(handle) 폐기 → canExecute(value)로 통합(이중 바인딩 게이트 겸함)
- unbindLifetime도 1-인자로 축소 — 호출부가 _mountedInst를 되짚을 필요 없어져
  "홀더가 갈아치워지면 해제가 빗나가는" 잠재 버그 클래스 소멸(slot-plan 5곳)
- gcconn/gchold를 lazy 생성에서 Instance 생성 시점으로 전환, 클로저가 inst까지
  캡처 — Instance userdata 포인터 동일성은 inst-키 Relate 전체의 전제였음
  (relate-plan에 "전제" 절 + "안전히 유지되면 항상 SetWeak" 일반 규칙 신설)
- canExecute의 실제 호출부를 State 전파 루프로 명시(구독자 weak + 발화마다
  게이팅) — 이게 코드로 한 번도 안 적힌 게 오류가 여섯 세션 살아남은 이유

역전 원문/오염 경로/교훈은 archive/canexecute-inst-arg-reversed.md.
luau-test/10은 폐기된 모델을 검증 중이라 rewrite-required/로 이동.

부수: 3~4차 세션이 남겨둔 CLAUDE.md 세션 히스토리 항목과 4차 세션 로그
파일도 미커밋 상태여서 같이 실림. doc-check ERROR 1건(CLAUDE.md:1156 →
session/2026-08-14-03-lifecycle-hooks-plan.md)은 그 파일이 디스크 어디에도
없어서 남음 — 3차 세션 쪽에서 채워야 함.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 04:03:49 +09:00

112 lines
6.9 KiB
Markdown

# 2026-08-14 네 번째 세션 — `ProcessedPreRef` 신설로 Length/Offset 등록 갭 해소, `PostRef` 완전 대칭화
## 배경 — 사용자 질문에서 시작된 읽기 전용 조사
사용자가 "PreRef 처리로 인해 공백이 생기면 Dispatch의 setLength/
setOffsetSource가 안 터지는가"를 물으며 **읽기 전용**을 명시(다른
에이전트가 같은 레포 파일을 편집 중이었음). Explore 에이전트로
`base/ref-plan.md`(PreRef pre-pass)와 `base/dispatch-core-plan.md`
(Length/Offset)를 조사한 결과:
- 설계상으로는 "충돌 없음"으로 다뤄져 있었음 — PreRef pre-pass가 소진시킨
슬롯은 `Dispatch.setLength(inst,i,0)`/`setOffsetSource(inst,i,None)`으로
등록돼야 한다고 `dispatch-core-plan.md`가 명시.
- 그런데 **누가 그 등록을 실제로 호출하는지가 어느 문서에도 없는 진짜
갭**이었음. 소진 값이 `None`이었기 때문에, 정상 두 패스 스캔이 그
자리를 `Dispatch.process`/핸들러 매칭 자체를 안 거치고 드라이버 루프
자신이 `if v == None then continue end`로 직접 건너뜀 — 그런데
Length/Offset 등록 책임은 "이 위치를 처음 매치한 Handler"에게 있다고
못박혀 있었으니(`dispatch-core-plan.md` "Length/Offset" 절), 애초에
매치되는 Handler 자체가 없는 그 자리는 등록 주체가 없었음.
## 사용자 제안 1 — `ProcessedPreRef` 전용 센티널 + `ProcessedPreRefHandler`
사용자가 해법 제시: PreRef pre-pass 소진 값을 `None`이 아니라 전용
센티널 `ProcessedPreRef`(단일 `{}`, `None`과 같은 급의 유니크 키)로 바꾸고,
그 값을 매치하는 `ProcessedPreRefHandler`를 정상 우선순위 레지스트리에
등록 — 이 Handler가 `setLength(0)`/`setOffsetSource(None)`을 등록하고
no-op retract를 반환. 기존 "이미 있는 걸 재활용", "매치되는 Handler가
곧 등록자"라는 원칙을 그대로 타서 특수 취급이 없어짐. `PreRefHandler`
(동적 경로 가드, 정상 스캔에 원본 `isPreRef(v)`가 걸리면 error)는 그대로
유지.
### 반영
- `base/ref-plan.md` "PreRef" 절 — `flattened[i] = None``= ProcessedPreRef`
교체, `ProcessedPreRefHandler` pseudocode 신설. 파생 서술 3곳도 같이
정정:
- "동적 경로 가드" Handler 설명의 "정상 두 패스 스캔에 다시 노출 안
됨" → "이 가드 Handler에는 다시 노출 안 됨(스캔 자체엔 노출됨)"으로
정확화.
- "PreRef는 취소 개념이 없다"의 근거를 "체인에 안 올라감"에서
"체인엔 올라가지만 retract가 하드코딩된 no-op(fire의 부작용을
되돌릴 방법이 없어서)"로 정정 — `ProcessedPreRefHandler` 신설로
"체인에 안 올라간다"는 옛 전제 자체가 깨졌기 때문.
- sparse-table 회피 근거의 "None으로 소진" 표현을 "실재하는 센티널로
소진"으로 일반화.
- `base/dispatch-core-plan.md``None` 센티널 절과 Length/Offset 절
두 곳에서 "PreRef pre-pass 소진 슬롯도 None 목록에 포함"이라던 서술을
제거하고, `ProcessedPreRefHandler`가 그 등록을 전담한다고 명시. `
sourceList``None`을 쓰는 이유 문단의 PreRef 인용도 갱신.
- `ROADMAP.md` M0/M8 체크리스트 — `None` 서술을 정정하고
`ProcessedPreRefHandler` 구현 항목 신설.
- `luau-test/done/02-none-sentinel-vs-nil-holes.luau` — 주석에 센티널
개명 사실만 추가(스크립트가 검증하는 성질 자체는 "실재하는 non-nil
값이면 순서/`#t`가 안 깨진다"는 일반 성질이라 어느 센티널을 쓰든
결과는 유효, 재작성/재실행 불필요).
## 사용자 제안 2 — `PostRef`도 같이, 그런데 더 단순하게
사용자가 `research/lifecycle-hooks-plan.md`의 백로그 `PostRef` 스케치도
같은 방식으로 갱신하자고 제안. 1차로 필자가 "PreRef는 소진 **후**
값이 매치 대상, PostRef는 fire가 뒤로 미뤄지니 소진 **전** 원본 값이
매치 대상이어야 한다"는 비대칭 설계를 초안으로 썼는데, 사용자가 더 나은
배선을 제시: **PreRef pre-pass가 이미 배열 전체를 index 순서로 한 번
훑고 있으니, 같은 스윕에서 `isPostRef(v)`도 같이 잡아 즉시
`ProcessedPostRef`로 소진하고 그 인스턴스를 `postRefList`(그
`Dispatch.drive` 호출 하나에만 로컬인 평범한 배열)에 순서대로 적재해두면
된다.** 그러면:
- `PreRef`/`PostRef`가 **소진 메커니즘 완전 대칭**(둘 다 pre-pass에서
즉시 `Processed*`로 소진, 둘 다 전담 `Processed*Handler`
Length/Offset 등록) — 유일한 차이는 "실제 콜백을 언제 부르는가"뿐.
- 별도 후행 전체 재순회(두 번째 `for i=1,N`)가 필요 없어짐 — 두 패스가
끝난 뒤 `postRefList`만 순서대로 소비하면 끝.
- 1차 초안이 필요하다고 짚었던 "PostRef 전용 비대칭 규칙"(원본 값이
매치 대상, `PostRefHandler`가 raw `PostRef`를 정상 스캔에서 잡아
등록) 자체가 안 생김.
`research/lifecycle-hooks-plan.md`의 ② 절(`OnRendered`/`PostRef`, 여전히
"의도적으로 지금 구현 안 함" 백로그 상태 그대로)을 이 설계로 갱신 — 스코프
논의((a)/(b)/(c) 선택지)도 "후행 스캔" 표현을 "`postRefList` 소비"로
정정.
## 검증
`python3 .claude/tools/doc-check.py` — 편집 전/후 모두 **ERROR 0건**,
WARN 101건(개수 동일, 기존 false-positive 그대로 — `git stash`로 대조
확인). 새로 도입한 문장이 만든 신규 WARN 없음.
## 커밋
사용자가 "지금 커밋해도 될듯"(워크트리 바깥 메인을 아무도 안 만짐)이라고
해서 커밋 `e0ef7ce`(`docs(dispatch): PreRef 소진 센티널을
ProcessedPreRef로 교체, Length/Offset 등록 갭 해소`)로 반영 — 로컬
`main`에만, push 안 함(`SAFETY.md`).
## code-review 두 차례 시도, 결과 미도착
커밋 전에 사용자가 `/code-review high`를 두 번 돌림(첫 번째 실행 도중
"커밋은 바로 하지 마, code-review 돌릴게"라고 지시). 두 번 다 8개(→5개)
관점 파인더 중 마지막 하나가 안 끝난 상태로 세션 안에서 최종 결과가
도착하지 않았음 — 사용자가 두 번째 실행도 "안 온다"며 포기하고 커밋을
바로 지시. **code-review 결과 자체는 이 세션에 반영되지 않음** — 나중에
결과가 도착하면 별도로 검토해 필요하면 후속 커밋으로 반영할 것.
## 반영 상태
새로 연 설계 질문 없음 — `ProcessedPreRef`/`ProcessedPreRefHandler`는
이미 확정된 PreRef 메커니즘의 정제(같은 결론을 특수 취급 없이 만족시키는
재배선)라 `question.md`에 올릴 항목 없음. `PostRef`/`OnRendered`는
여전히 백로그 상태 그대로(우선순위·"착수 여부 미정" 변경 없음) — 착수
시점에 이번에 정리된 대칭 설계를 그대로 가져다 쓰면 됨.