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

# Plan - Ingress snapshot backing ownership and fail-closed accounting

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

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

## 배경

현재 snapshot ledger는 typed overflow를 반환한 뒤에도 제한 초과 snapshot을 만들 수 있고, handle을 실제 builder-owned backing이 아닌 길이로만 식별한다. 또한 `Build`가 payload를 다시 복사해 SDD가 요구한 conservative logical accounting과 canonical single-copy를 깨뜨린다. 이 follow-up은 backing ownership과 fail-closed state를 같은 package 안에서 바로잡고 재현 fixture로 고정한다.

## 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_G08_1.log`
  - Review: `agent-task/m-stream-evidence-gate-core/15+12_ingress_snapshot_ledger/code_review_cloud_G08_1.log`
- Verdict: `FAIL`
- Findings: Required 3, Suggested 0, Nit 0
- 영향 파일: `packages/go/streamgate/ingress_snapshot.go`, `packages/go/streamgate/ingress_snapshot_test.go`
- 실제 검증:
  - `go test -count=1 ./packages/go/streamgate`와 `go test -race -count=1 ./packages/go/streamgate`는 통과했다.
  - 집중 재현은 max=3/canonical=`abc`/typed=`x` overflow 뒤 retained=4 snapshot이 만들어지고, 다른 builder가 발급한 handle이 무관한 동일 길이 payload에 허용되는 두 실패를 확인했다.
- 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`

### SDD 기준

- Milestone: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/stream-evidence-gate-core.md`, 상태 `[진행중]`, 구현 잠금 해제.
- SDD: `agent-roadmap/sdd/knowledge-tool-optimization-extension/stream-evidence-gate-core/SDD.md`, 상태 `[승인됨]`, SDD 잠금 해제.
- Acceptance S15/`request-rebuilder`: OpenAI raw body canonical source는 하나만 보존하고 bounded typed backing을 계상한다.
- Acceptance S22/`request-rebuilder`: owned backing은 합산하되 shared backing은 한 번만 세며 typed overflow는 no-snapshot/no-dispatch로 fail-closed하고 retained object를 release한다.
- Evidence Map S22의 exact-limit+typed overflow, shared-backing logical peak, release/no-dispatch 조건을 ownership-transfer, poisoned-overflow, foreign-handle regression checklist와 최종 unit/race 검증으로 역산했다. S15의 serializer/host admission/path-switch evidence는 후속 host integration 소유라 이 pair에서 완료 처리하지 않는다.

### 테스트 환경 규칙

- `test_env=local`.
- `agent-test/local/rules.md`를 읽었다. PATH의 host Go identity를 먼저 기록하고 현재 checkout에서 fresh 대상 package 테스트를 실행한다.
- `agent-test/local/platform-common-smoke.md`를 읽었다. 이 변경은 proto/config/Edge-Node wire를 바꾸지 않으므로 transport consumer/full-cycle 항목은 적용하지 않고, local quick check의 `make proto` 안정성, 대상 package unit/race, `git diff --check`를 적용한다.
- `agent-ops/rules/project/domain/platform-common/rules.md`와 `agent-ops/rules/project/domain/testing/rules.md`를 읽었다.
- `<확인 필요>` 값과 외부/remote preflight는 없다. 아직 사용자 실행 파이프라인에 연결되지 않은 transport-agnostic package primitive이므로 Edge-Node 진단, mock/live smoke, full-cycle은 이 pair에서 생략한다.
- Go test cache는 허용하지 않고 `-count=1`을 사용한다. 테스트 룰 유지보수는 필요 없다.

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

- 기존 boundary fixture는 constructor limit-1/limit/limit+1을 검증한다: 유지.
- 기존 typed overflow fixture는 오류와 retained=101만 확인하고 이후 `Build` 성공을 막지 못한다: 회귀 fixture 필요.
- 기존 shared-backing fixture는 동일 slice를 반복 전달할 뿐 foreign builder handle과 same-length payload 치환을 막지 못한다: 회귀 fixture 필요.
- 기존 multiple-canonical fixture는 API 재호출만 확인하고 builder→snapshot ownership transfer 뒤 canonical full-copy 단일성을 증명하지 않는다: 내부 ownership fixture 필요.
- 기존 race fixture는 accessor/`Close` 경쟁을 검증한다: 새 backing store와 transfer 구조에 맞게 유지·보강한다.

### 심볼 참조

- `rg --sort path` 결과 `IngressSnapshot`, `BackingHandle`, `TypedSemanticView` public symbol의 현재 코드 호출자는 `ingress_snapshot_test.go`뿐이다.
- 외부 package call site는 없다. handle association signature를 안전하게 좁혀도 현재 수정 범위는 같은 source/test 두 파일로 닫힌다.

### 분할 판단

- split decision policy를 재평가했다. 이 작업은 한 `packages/go/streamgate` public primitive의 handle identity, accounting transition, ownership transfer가 같은 backing store 표현을 공유하므로 별도 child로 나누면 중간 API가 불일치한다.
- 기존 split 경로 `15+12_ingress_snapshot_ledger`를 그대로 유지한다. predecessor `12+11_config_policy`는 `agent-task/archive/2026/07/m-stream-evidence-gate-core/12+11_config_policy/complete.log`로 충족됐다.
- broad call-site rollout, 다중 domain, UI/외부 검증 경계가 없고 두 파일을 한 unit/race suite로 판정할 수 있어 single follow-up pair가 안전하다.

### 범위 결정 근거

- 포함: snapshot builder-owned backing identity, retained-byte accounting, overflow terminal state, builder→snapshot ownership transfer, accessor/close 호환, 직접 회귀 테스트.
- 제외: rebuild temporary reserve/commit, Recovery Coordinator, HTTP limit+1, Chat/Responses serializer/patch, Edge/Node host wiring, config 변경. 각각 후속 split task가 소비할 경계이며 현재 Required 3건을 고치는 데 필요하지 않다.
- `agent-roadmap/**`, `agent-contract/**`, `agent-spec/**`는 구현에서 수정하지 않는다.

### 최종 라우팅

- `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 unit/race로 성공 판정이 가능하며, 외부 call site·사용자 결정이 없다는 점이다.
- Build route: `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 observations:
  - ordered transitions: state_count=4(open/failed/built/closed), adverse_paths=overflow/duplicate-build/close, evidence=`ingress_snapshot.go:340-527`.
  - concurrent consistency: actor_count=2(snapshot accessor/close callers), constraints=atomic ownership transfer·race-free close·idempotent release, evidence=`ingress_snapshot_test.go:148-269`.
  - boundary contract: component_count=3(builder/snapshot/lease), consumer_count=1(current tests; future rebuilder excluded), constraints=immutability·builder-bound handle·single backing·limit validation.
  - structured interpretation: mechanisms=[], hazards=[], evidence=byte backing ledger이며 parser/merge가 아니다.
  - variant product: independent_axis_count=3(canonical/typed, unique/shared, success/overflow), combination_verification_required=true.
  - matched signatures: temporal_state, concurrent_consistency, boundary_contract, variant_product; triggered=true. Signature는 route/G에 영향을 주지 않았다.
- Review closures도 모두 `true`. 공식 리뷰가 source ownership, fail-closed transition, regression output을 같은 package/SDD 근거로 독립 판정할 수 있다.
- Review route: `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`.

## 구현 체크리스트

- [x] REVIEW_INGRESS_SNAPSHOT_LEDGER-1 handle을 발급 builder와 builder-owned backing에 결속하고 shared backing을 한 번만 보유·계상한다.
- [x] REVIEW_INGRESS_SNAPSHOT_LEDGER-2 overflow를 terminal fail-closed state로 만들고 `Build`에서 backing ownership을 복사 없이 snapshot으로 이전한다.
- [x] REVIEW_INGRESS_SNAPSHOT_LEDGER-3 foreign handle, same-length 치환, overflow-after-error build, ownership transfer/single-copy 회귀를 추가하고 fresh proto/unit/race/diff 검증을 수행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.

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

- 문제: `packages/go/streamgate/ingress_snapshot.go:33`의 handle은 local `id`만 갖고, `:320-329`은 payload 길이만 저장한다. `:365-417`은 같은 길이의 임의 payload를 별도 복사하므로 foreign handle collision과 false shared-backing 계상이 가능하다.
- 해결 방법: handle에 unexported builder owner id를 포함하고 builder가 발급 시 payload를 정확히 한 번 복사·소유한다. association API는 payload를 다시 받지 않고 handle만 받아 stored backing을 canonical/typed metadata에 연결한다. 전역 owner id 발급에는 표준 library `sync/atomic`을 사용하고 pointer/`unsafe`/heap introspection은 사용하지 않는다.

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

```go
type BackingHandle struct {
    id uint64
}
hdl := BackingHandle{id: b.nextID}
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, single builder-owned backing store, metadata-only canonical/typed association을 구현한다.
  - [ ] caller input mutation이 builder-owned payload를 바꾸지 않고 foreign/zero/unknown handle이 `ErrIngressSnapshotInvalidHandle`로 거부되게 한다.
- 테스트 작성: `packages/go/streamgate/ingress_snapshot_test.go`에 `TestIngressSnapshot_ForeignHandleRejected`와 `TestIngressSnapshot_SharedHandleUsesSingleOwnedBacking`을 추가한다. 다른 builder handle, 같은 길이 다른 payload 경로, unique/shared retained count와 returned payload를 검증한다.
- 중간 검증: `go test -count=1 ./packages/go/streamgate -run 'TestIngressSnapshot_(ForeignHandleRejected|SharedHandleUsesSingleOwnedBacking)$'` — PASS.

### [REVIEW_INGRESS_SNAPSHOT_LEDGER-2] Transactional overflow and ownership transfer

- 문제: `packages/go/streamgate/ingress_snapshot.go:445-460`은 state를 먼저 증가시킨 뒤 limit 오류를 반환하고, `:488-526`은 failed state를 확인하지 않은 채 canonical/typed payload를 다시 복사하고 builder 원본도 유지한다.
- 해결 방법: 새 backing을 보유하기 전에 overflow-safe subtraction으로 candidate size를 검사한다. overflow면 stable failure를 저장하고 canonical/typed/backing reference를 즉시 지운 뒤 모든 mutation과 `Build`가 같은 limit error를 반환하게 한다. 성공 `Build`는 backing map과 metadata ownership을 snapshot으로 이전하고 builder reference를 nil로 만든다. snapshot accessor만 defensive output copy를 만들고 `Close`가 owned backing을 해제한다.

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

```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`: overflow terminal state, reference cleanup, stable subsequent error를 구현한다.
  - [ ] `Build`에서 payload full copy를 제거하고 metadata/backing ownership을 한 번만 이전한다.
  - [ ] zero-length non-nil canonical presence를 slice nil 여부가 아닌 explicit handle/state로 판정한다.
- 테스트 작성: `TestIngressSnapshot_OverflowPoisonsBuilderAndReleases`, `TestIngressSnapshot_BuildTransfersBackingOwnership`, zero-length canonical subcase를 추가한다. overflow 뒤 `Build` 실패/내부 reference release, source mutation 격리, builder reference nil, snapshot canonical/typed accessor와 idempotent `Close`를 검증한다.
- 중간 검증: `go test -count=1 ./packages/go/streamgate -run 'TestIngressSnapshot_(OverflowPoisonsBuilderAndReleases|BuildTransfersBackingOwnership|ImmutableSnapshotAndLeaseLifecycle)$'` — PASS.

### [REVIEW_INGRESS_SNAPSHOT_LEDGER-3] Regression and race closure

- 문제: `packages/go/streamgate/ingress_snapshot_test.go:41-125`의 기존 fixture는 error 반환까지만 확인해 no-snapshot/no-dispatch와 true backing identity를 증명하지 못한다.
- 해결 방법: 위 집중 재현을 permanent table/subtests로 옮기고 기존 boundary/multiple-canonical/race fixture를 새 API에 맞춘다. public accessor defensive-copy와 concurrent `Close` 동작을 유지한다.
- 수정 파일 및 체크리스트:
  - [ ] `packages/go/streamgate/ingress_snapshot_test.go`: 모든 Required variant와 limit-1/limit/limit+1 회귀를 검증한다.
  - [ ] `rg --sort path`로 외부 call site가 새로 생기지 않았는지 확인한다.
- 테스트 작성: 새 public API의 normal/boundary, ownership, failure-state, concurrency 회귀가 필수다. 별도 integration test는 host wiring이 범위 밖이므로 작성하지 않는다.
- 중간 검증: `go test -count=1 ./packages/go/streamgate && go test -race -count=1 ./packages/go/streamgate` — 모두 PASS.

## 수정 파일 요약

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

## 최종 검증

- `command -v go && readlink -f "$(command -v go)" && go version && go env GOROOT` — host Go/GOROOT source 일치.
- `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'` — 의도한 source/test 참조만 존재.
- `git diff --check` — whitespace error 없음.

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