<!-- task=m-stream-evidence-gate-core/15+12_ingress_snapshot_ledger plan=3 tag=REVIEW_REVIEW_INGRESS_SNAPSHOT_LEDGER -->

# Plan - Ingress snapshot backing identity, fail-closed state, and transfer closure

## 이 파일을 읽는 구현 에이전트에게

구현, 테스트, 검증 evidence만 소유한다. 아래 체크리스트를 완료하고 모든 명령의 실제 stdout/stderr와 설계·변경 사항을 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션에 채운 뒤 active pair를 그대로 두고 리뷰 준비 완료를 보고한다. 차단되면 구현 소유 evidence 필드에 정확한 blocker, 시도한 명령/출력, 재개 조건만 기록한다. 사용자에게 질문하거나 user-input/control-plane stop 파일을 만들거나 다음 상태를 분류하지 않으며, log archive·`complete.log`·리뷰 판정은 수행하지 않는다.

## 배경

직전 follow-up은 체크리스트를 완료로 표시했지만 이전 FAIL의 길이 기반 handle, overflow 뒤 build 허용, `Build` 재복사를 그대로 유지했다. 계획된 foreign-handle, poisoned-overflow, ownership-transfer 테스트도 없어 fresh selector가 `no tests to run`으로 통과했다. 이 follow-up은 builder-owned backing 하나를 identity/accounting/transfer의 단일 source로 만들고 SDD S15/S22 회귀로 종결한다.

## Archive Evidence Snapshot

- 이전 task: `agent-task/m-stream-evidence-gate-core/15+12_ingress_snapshot_ledger`
- 현재 닫은 pair:
  - Plan: `agent-task/m-stream-evidence-gate-core/15+12_ingress_snapshot_ledger/plan_local_G06_2.log`
  - Review: `agent-task/m-stream-evidence-gate-core/15+12_ingress_snapshot_ledger/code_review_cloud_G06_2.log`
- Verdict: `FAIL`
- Findings: Required 3, Suggested 0, Nit 0
- 영향 파일: `packages/go/streamgate/ingress_snapshot.go`, `packages/go/streamgate/ingress_snapshot_test.go`
- 실제 검증:
  - 필수 네 회귀 이름을 선택한 fresh `go test`는 `testing: warning: no tests to run`으로 끝났다.
  - `go test -count=1 ./packages/go/streamgate`와 `go test -race -count=1 ./packages/go/streamgate`는 기존 약한 fixture로 통과했다.
  - `make proto`, proto 생성물 diff, symbol search, `git diff --check`는 통과했다.
- Roadmap carryover: 승인된 Stream Evidence Gate Core SDD의 S15/S22와 `request-rebuilder` 경계를 유지하되, 이 하위 작업 PASS만으로 Milestone Task 완료를 주장하지 않는다.
- 상세 재열람이 필요할 때만 위 plan/review log 두 파일을 읽는다.

## 분석 결과

### 읽은 파일

- `packages/go/streamgate/ingress_snapshot.go`
- `packages/go/streamgate/ingress_snapshot_test.go`
- `agent-task/m-stream-evidence-gate-core/15+12_ingress_snapshot_ledger/plan_local_G06_2.log`
- `agent-task/m-stream-evidence-gate-core/15+12_ingress_snapshot_ledger/code_review_cloud_G06_2.log`
- `agent-task/m-stream-evidence-gate-core/15+12_ingress_snapshot_ledger/code_review_cloud_G08_1.log`
- `agent-ops/rules/project/domain/platform-common/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/platform-common-smoke.md`
- `agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md`
- `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/stream-evidence-gate-core.md`
- `agent-roadmap/sdd/knowledge-tool-optimization-extension/stream-evidence-gate-core/SDD.md`

### SDD 기준

- Milestone은 `[진행중]`, 구현 잠금은 해제이고 SDD는 `[승인됨]`, SDD 잠금은 해제다.
- S15/`request-rebuilder`: OpenAI raw body canonical source를 하나만 보존하고 bounded typed backing을 계상한다.
- S22/`request-rebuilder`: owned backing은 합산하고 shared backing은 한 번만 세며 overflow는 no-snapshot/no-dispatch로 fail-closed하고 retained object를 release한다.
- Evidence Map S22의 exact-limit+typed overflow, shared-backing logical peak, release 조건을 owner-bound handle, poisoned overflow, transfer/single-copy 회귀로 역산했다. S15의 host admission/serializer/path-switch는 후속 integration 소유이며 이 pair는 Milestone Task 완료를 주장하지 않는다.

### 테스트 환경 규칙

- `test_env=local`; `agent-test/local/rules.md`와 `agent-test/local/platform-common-smoke.md`를 읽었다.
- PATH 첫 host Go의 binary/GOROOT identity를 기록하고 현재 checkout에서 `-count=1` package unit/race를 실행한다.
- transport-agnostic package primitive이며 proto/config/wire와 사용자 실행 entrypoint를 바꾸지 않으므로 Edge-Node full-cycle과 외부 smoke는 적용하지 않는다. `make proto` 안정성, 대상 package unit/race, deterministic symbol search, `git diff --check`를 적용한다.
- `<확인 필요>` 값, remote runner, secret, 외부 service는 없다. 테스트 룰 유지보수는 필요 없다.

### 테스트 커버리지 공백

- `TestIngressSnapshot_ExactLimitAndTypedOverflow`는 overflow 뒤 retained=101을 기대하며 이후 `Build` 성공을 막지 않는다.
- `TestIngressSnapshot_SharedBackingCountedOnce`는 같은 길이 payload를 반복 전달할 뿐 builder/payload identity를 검증하지 않는다.
- `TestIngressSnapshot_ProhibitMultipleCanonicalCopies`는 API 재호출만 확인하고 builder→snapshot backing transfer를 증명하지 않는다.
- 계획된 `ForeignHandleRejected`, `SharedHandleUsesSingleOwnedBacking`, `OverflowPoisonsBuilderAndReleases`, `BuildTransfersBackingOwnership` 테스트는 존재하지 않는다.

### 심볼 참조

- `BackingHandle`, `IngressSnapshotBuilder`의 association API 호출자는 현재 `packages/go/streamgate/ingress_snapshot_test.go`뿐이다.
- `IngressSnapshot`은 같은 package의 `packages/go/streamgate/recovery_plan.go` `RequestRebuilder` interface에서 포인터 타입으로만 소비한다. 이번 변경은 snapshot 타입과 public accessor를 유지하므로 해당 interface/caller 수정은 없다.
- 새 dependency는 표준 library `sync/atomic`뿐이며 `go.mod` 변경은 없다.

### 분할 판단

- split decision policy를 재평가했다. handle identity, overflow state, transfer는 같은 backing store 표현을 공유하고 한 package unit/race suite에서만 완결되므로 추가 분할하면 중간 API가 불일치한다.
- 기존 split 경로 `15+12_ingress_snapshot_ledger`를 유지한다. predecessor `12`는 `agent-task/archive/2026/07/m-stream-evidence-gate-core/12+11_config_policy/complete.log`로 충족됐다.
- broad call-site rollout, 다중 domain, UI/외부 검증 경계가 없어 single follow-up pair가 더 안전하다.

### 범위 결정 근거

- 포함: builder owner/backing identity, retained-byte ledger, overflow terminal state, builder→snapshot zero-copy transfer, accessor/close 호환, 직접 회귀 테스트.
- 제외: `recovery_plan.go` interface 변경, rebuild temporary peak, host pre-read limit, Chat/Responses serializer, Edge/Node wiring, config/contract/roadmap/spec 갱신. 이들은 현재 Required 3건과 독립인 후속 split 범위다.

### 최종 라우팅

- `evaluation_mode=isolated-reassessment`, `finalizer=finalize-task-policy.sh`, `finalizer_mode=pair`.
- Build closures: scope/context/verification/evidence/ownership/decision 모두 `true`. source/test 두 파일과 SDD S15/S22가 정확성 조건을 닫고, fresh named regression+unit/race로 성공 판정 가능하며 외부 결정이 없다.
- Build route basis `local-fit`, capability gap `none`, grade scores `scope_coupling=1, state_concurrency=2, blast_irreversibility=1, evidence_diagnosis=1, verification_complexity=1`, lane/grade `local/G06`, filename `PLAN-local-G06.md`, G floor `none`.
- Build loop-risk: ordered transitions state_count=4(open/failed/built/snapshot-closed), adverse paths=overflow/duplicate-build/close; concurrent actor_count=2(accessor/close), atomic transfer·race-free close 제약; boundary component_count=3(builder/snapshot/lease), consumer_count=1; structured mechanisms/hazards 없음; variant axes=3(canonical/typed, unique/shared, success/overflow), combination verification 필요. matched signatures=`temporal_state,concurrent_consistency,boundary_contract,variant_product`, triggered=`true`; signature는 route/G에 영향 없음.
- Review closures도 모두 `true`; source ownership, state 전이, SDD 회귀 출력으로 독립 판정 가능하다.
- Review route basis `official-review`, capability gap `none`, grade scores `1/2/1/1/1`, lane/grade `cloud/G06`, filename `CODE_REVIEW-cloud-G06.md`, target Codex `gpt-5.6-sol` xhigh, G floor `none`.

## 구현 체크리스트

- [ ] REVIEW_REVIEW_INGRESS_SNAPSHOT_LEDGER-1 handle을 발급 builder와 builder-owned backing payload에 결속하고 association이 stored backing만 참조하게 한다.
- [ ] REVIEW_REVIEW_INGRESS_SNAPSHOT_LEDGER-2 overflow를 reference-releasing terminal state로 만들고 성공 `Build`는 backing/metadata ownership을 복사 없이 snapshot으로 이전한다.
- [ ] REVIEW_REVIEW_INGRESS_SNAPSHOT_LEDGER-3 foreign/same-length, shared backing, overflow-after-error build, zero-length canonical, transfer/single-copy 회귀를 추가하고 fresh proto/unit/race/diff 검증을 수행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.

### [REVIEW_REVIEW_INGRESS_SNAPSHOT_LEDGER-1] Builder-owned backing identity

- 문제: `packages/go/streamgate/ingress_snapshot.go:33,320-329,365-417`은 handle에 local id만 두고 payload 길이만 저장하며 association마다 임의 payload를 다시 받는다. 다른 builder의 같은 id와 동일 길이 payload를 구분하지 못한다.
- 해결 방법: `BackingHandle`에 unexported `ownerID`를 추가하고 builder마다 `sync/atomic`으로 non-zero owner id를 발급한다. `IssueBacking`이 limit 확인 뒤 payload를 한 번 복사해 `map[BackingHandle]snapshotBacking`에 소유하고, `SetCanonicalWithHandle(hdl)`과 `AddTypedView(name, hdl)`은 stored backing metadata만 연결한다.

Before (`ingress_snapshot.go:33,326-329,365`):

```go
type BackingHandle struct { id uint64 }
b.handles[hdl] = uint64(len(data))
func (b *IngressSnapshotBuilder) SetCanonicalWithHandle(hdl BackingHandle, canonical []byte) error
```

After:

```go
import (
    "errors"
    "sync"
    "sync/atomic"
)

type BackingHandle struct {
    ownerID uint64
    id      uint64
}

type snapshotBacking struct { payload []byte }

func (b *IngressSnapshotBuilder) SetCanonicalWithHandle(hdl BackingHandle) error
func (b *IngressSnapshotBuilder) AddTypedView(name string, hdl BackingHandle) error
```

- 수정 파일 및 체크리스트:
  - [ ] `packages/go/streamgate/ingress_snapshot.go`: owner-bound handle, builder-owned backing store, metadata-only association을 구현한다.
  - [ ] caller input mutation이 stored payload를 바꾸지 않고 foreign/zero/unknown handle이 `ErrIngressSnapshotInvalidHandle`로 거부되게 한다.
- 테스트 작성: `packages/go/streamgate/ingress_snapshot_test.go`의 `TestIngressSnapshot_ForeignHandleRejected`, `TestIngressSnapshot_SharedHandleUsesSingleOwnedBacking`에서 다른 builder handle, 같은 길이의 서로 다른 local backing, input mutation 격리, unique/shared retained count와 returned payload를 검증한다.
- 중간 검증: `go test -count=1 ./packages/go/streamgate -run 'TestIngressSnapshot_(ForeignHandleRejected|SharedHandleUsesSingleOwnedBacking)$'` — 두 테스트가 실제 실행되고 PASS.

### [REVIEW_REVIEW_INGRESS_SNAPSHOT_LEDGER-2] Terminal overflow and ownership transfer

- 문제: `packages/go/streamgate/ingress_snapshot.go:451-460`은 state를 먼저 증가시킨 뒤 limit error를 반환하고 `:488-526`은 failed state 없이 payload/map을 다시 복사하며 builder reference를 유지한다.
- 해결 방법: backing 소유 전에 `size > maxBytes-retainedBytes`를 검사하고 초과 시 `terminalErr`를 고정하며 모든 backing/canonical/typed reference를 해제한다. 이후 모든 mutation과 `Build`는 같은 error를 반환한다. 성공 `Build`는 backing map과 handle metadata를 snapshot으로 이동하고 builder reference를 nil로 만든다. snapshot accessor만 caller-facing defensive copy를 만들고 canonical 존재는 slice nil이 아니라 explicit handle/state로 판정한다.

Before (`ingress_snapshot.go:451-460,500-514`):

```go
b.accounted[hdl] = true
b.retainedBytes += sz
if b.retainedBytes > b.maxBytes { return ErrIngressSnapshotLimitExceeded }
snapCanonical := append([]byte(nil), b.canonical...)
```

After:

```go
if size > b.maxBytes-b.retainedBytes {
    return b.failLocked(ErrIngressSnapshotLimitExceeded)
}
b.retainedBytes += size

snapshot.backings = b.backings
b.backings = nil
b.typedViews = nil
b.canonicalHdl = BackingHandle{}
```

- 수정 파일 및 체크리스트:
  - [ ] `packages/go/streamgate/ingress_snapshot.go`: stable terminal overflow, immediate reference cleanup, subsequent error precedence를 구현한다.
  - [ ] 성공 `Build`에서 payload full copy를 없애고 metadata/backing ownership을 한 번만 이전한다.
  - [ ] zero-length non-nil canonical을 explicit handle/state로 보존하고 `Close`가 transferred backing을 해제한다.
- 테스트 작성: `TestIngressSnapshot_OverflowPoisonsBuilderAndReleases`, `TestIngressSnapshot_BuildTransfersBackingOwnership`에 overflow 뒤 retained reference 없음/동일 error/Build 실패, caller mutation 격리, pre/post-transfer backing identity, builder nil reference, zero-length canonical, snapshot accessor와 idempotent close를 검증한다.
- 중간 검증: `go test -count=1 ./packages/go/streamgate -run 'TestIngressSnapshot_(OverflowPoisonsBuilderAndReleases|BuildTransfersBackingOwnership|ImmutableSnapshotAndLeaseLifecycle)$'` — 세 테스트가 실제 실행되고 PASS.

### [REVIEW_REVIEW_INGRESS_SNAPSHOT_LEDGER-3] Regression and verification closure

- 문제: `packages/go/streamgate/ingress_snapshot_test.go:41-125`는 error 반환과 길이 중복 계상만 확인하고 owner identity, no-snapshot, release, zero-copy transfer를 검증하지 않는다.
- 해결 방법: 기존 boundary/shared/canonical/race fixture를 새 API와 terminal state에 맞추고 필수 네 test 이름을 permanent regression으로 추가한다. focused selector 출력에 `=== RUN` 네 이름이 실제 나타나는지 evidence에 남긴다.
- 수정 파일 및 체크리스트:
  - [ ] `packages/go/streamgate/ingress_snapshot_test.go`: limit-1/limit/limit+1, foreign/local identity, unique/shared, overflow terminal, transfer/zero-length, accessor/close/race 회귀를 검증한다.
  - [ ] `rg --sort path`로 association API와 public type call site가 의도한 package 범위로 닫히는지 확인한다.
- 테스트 작성: 위 네 신규 회귀와 기존 normal/boundary/race fixture를 모두 유지·갱신한다. host integration은 범위 밖이므로 추가하지 않는다.
- 중간 검증: `go test -count=1 ./packages/go/streamgate && go test -race -count=1 ./packages/go/streamgate` — fresh unit/race 모두 PASS.

## 수정 파일 요약

| 파일 | 항목 |
|---|---|
| `packages/go/streamgate/ingress_snapshot.go` | REVIEW_REVIEW_INGRESS_SNAPSHOT_LEDGER-1/2 |
| `packages/go/streamgate/ingress_snapshot_test.go` | REVIEW_REVIEW_INGRESS_SNAPSHOT_LEDGER-1/2/3 |

## 최종 검증

- `command -v go && readlink -f "$(command -v go)" && go version && go env GOROOT` — host Go/GOROOT source 일치.
- `go test -count=1 ./packages/go/streamgate -run 'TestIngressSnapshot_(ForeignHandleRejected|SharedHandleUsesSingleOwnedBacking|OverflowPoisonsBuilderAndReleases|BuildTransfersBackingOwnership)$' -v` — 네 신규 회귀가 모두 실제 실행되고 PASS; `no tests to run` 금지.
- `make proto && git diff --exit-code -- proto/gen` — 생성 계약 변화 없음.
- `go test -count=1 ./packages/go/streamgate` — fresh unit PASS.
- `go test -race -count=1 ./packages/go/streamgate` — fresh race PASS.
- `rg --sort path -n 'IngressSnapshot|BackingHandle|TypedSemanticView' packages/go --glob '*.go'` — 의도한 package source/test/interface 참조만 존재.
- `git diff --check` — whitespace error 없음.

모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다.
