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

# Plan - Ingress snapshot canonical contract, observable terminal state, and transfer evidence closure

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

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

## 배경

owner-bound backing과 terminal overflow 구현은 fresh unit/race를 통과했지만 canonical 없는 snapshot을 허용하고, backing 발급 mutation은 terminal error를 반환할 수 없으며, transfer 회귀는 실제 backing identity를 증명하지 않는다. 이 follow-up은 원래 SNAPSHOT-1과 승인된 SDD S15/S22의 canonical source, stable fail-closed state, canonical single-copy 조건을 public API와 직접 assertion으로 닫는다.

## 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_3.log`
  - Review: `agent-task/m-stream-evidence-gate-core/15+12_ingress_snapshot_ledger/code_review_cloud_G06_3.log`
- Verdict: `FAIL`
- Findings: Required 3, Suggested 0, Nit 0
- 영향 파일: `packages/go/streamgate/ingress_snapshot.go`, `packages/go/streamgate/ingress_snapshot_test.go`
- 실제 검증:
  - 네 focused 회귀, `go test -count=1 ./packages/go/streamgate`, `go test -race -count=1 ./packages/go/streamgate`는 PASS했다.
  - `make proto`, proto 생성물 diff, deterministic symbol search, `git diff --check`는 PASS했다.
  - 통과한 suite가 no-canonical 거부, 발급 mutation의 stable error, pre/post backing pointer identity를 assertion하지 못했다.
- 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`
- `packages/go/streamgate/recovery_plan.go`
- `agent-task/m-stream-evidence-gate-core/15+12_ingress_snapshot_ledger/plan_local_G06_3.log`
- `agent-task/m-stream-evidence-gate-core/15+12_ingress_snapshot_ledger/code_review_cloud_G06_3.log`
- `agent-task/m-stream-evidence-gate-core/15+12_ingress_snapshot_ledger/plan_local_G08_0.log`
- `agent-task/archive/2026/07/m-stream-evidence-gate-core/12+11_config_policy/complete.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/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 S15의 canonical single-copy/retained-byte accounting과 S22의 shared-backing/release 조건에서 canonical-required Build, explicit mutation error, backing pointer transfer와 zero-state assertions를 역산했다. host admission, serializer, rebuild temporary peak는 후속 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` focused/package/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는 없다. 테스트 룰 유지보수는 필요 없다.

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

- `Build`가 canonical 미설정 상태에서도 성공하는 경로는 `TestIngressSnapshot_BuilderInputMutationIsolation`과 다른 builder fixture에서 오히려 허용된다. stable no-canonical error 회귀가 없다.
- `IssueBackingHandle`은 overflow/closed/built를 모두 zero handle로 접으므로 후속 mutation이 정확한 terminal state를 보존하는지 검증할 수 없다.
- `TestIngressSnapshot_BuildTransfersBackingOwnership`은 builder map nil과 accessor 결과만 확인하고 build 전 `*snapshotBacking`과 snapshot backing의 identity를 비교하지 않는다.
- overflow/Close 회귀는 backing map만 확인하며 current `retainedBytes` zero-state를 확인하지 않는다.

### 심볼 참조

- `IssueBackingHandle` signature 변경 대상 호출자는 `packages/go/streamgate/ingress_snapshot_test.go`뿐이다. 외부 package call site는 없다.
- `IngressSnapshot`은 `packages/go/streamgate/recovery_plan.go`의 `RequestRebuilder.RebuildRequest`에서 `*IngressSnapshot`으로만 소비되며 이번 accessor/type 경계 변경은 없다.
- `ErrIngressSnapshotNilCanonical`을 stable sentinel로 복구하면 새 직접 참조는 source와 `ingress_snapshot_test.go`에만 생긴다. 새 dependency는 없다.

### 분할 판단

- split decision policy를 재평가했다. canonical validity, issue error signature, terminal/transfer zero-state는 같은 `IngressSnapshotBuilder` 상태 기계와 같은 public call sites를 함께 바꿔야 하므로 API와 회귀를 나누면 중간 checkout이 compile되지 않는다.
- source/test 두 파일의 한 package suite로 모든 불변조건을 독립 검증할 수 있고 별도 domain, broad rollout, UI/외부 검증 경계가 없다. 기존 split 경로 `15+12_ingress_snapshot_ledger`의 단일 follow-up이 더 안전하다.
- predecessor `12`는 `agent-task/archive/2026/07/m-stream-evidence-gate-core/12+11_config_policy/complete.log`로 충족됐다.

### 범위 결정 근거

- 포함: canonical-required Build, `IssueBackingHandle`의 explicit error channel, terminal/closed/built error precedence, builder logical zero-state, actual backing identity regression, 직접 call-site 업데이트.
- 제외: `recovery_plan.go` interface 변경, rebuild temporary peak, HTTP 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_closed=true`(두 source/test 파일과 S15/S22가 정확성 조건을 고정), `context_closed=true`(모든 call site와 prior raw finding 확인), `verification_closed=true`(named regression+unit/race/proto/diff로 판정 가능), `evidence_trusted=true`(fresh 명령 출력과 코드가 일치), `ownership_closed=true`(같은 package 단일 소유), `decision_closed=true`(Milestone 결정 필요 없음).
- 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/closed), adverse paths `overflow/no-canonical/rebuild/close`; concurrent actor count `2`(snapshot accessor/Close), constraints `atomic transfer/race-free release`; boundary component count `3`(builder/snapshot/accessor), consumer count `1`; structured mechanisms/hazards 없음; variant axes `3`(canonical presence/length, open terminal state, unique/shared transfer), combination verification 필요. matched signatures=`temporal_state,concurrent_consistency,boundary_contract,variant_product`, triggered=`true`; signature는 route/G에 영향 없음.
- Review closures도 모두 `true`; source state, public call sites와 fresh regression output으로 독립 판정 가능하다.
- 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_REVIEW_INGRESS_SNAPSHOT_LEDGER-1 canonical 미설정 Build를 stable error로 거부하고 zero-length canonical은 유지한다.
- [ ] REVIEW_REVIEW_REVIEW_INGRESS_SNAPSHOT_LEDGER-2 backing 발급 mutation에 explicit error channel을 제공하고 overflow/closed/built error precedence를 보존한다.
- [ ] REVIEW_REVIEW_REVIEW_INGRESS_SNAPSHOT_LEDGER-3 성공 transfer와 terminal release 뒤 builder state를 nil/zero로 만들고 실제 backing identity/single-copy 회귀를 추가해 fresh 검증을 수행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.

### [REVIEW_REVIEW_REVIEW_INGRESS_SNAPSHOT_LEDGER-1] Canonical-required snapshot

- 문제: `packages/go/streamgate/ingress_snapshot.go:179-199`은 canonical handle 유효성을 확인하지 않고 snapshot을 만든다. `ingress_snapshot_test.go:475-482,827-828`은 이 상태의 성공을 허용해 SDD S15의 canonical lossless source가 사라진다.
- 해결 방법: zero-length payload도 non-zero handle로 구분하는 현재 표현을 유지하면서 `Build` transfer 전에 canonical handle의 존재와 backing association을 확인한다. 미설정은 stable `ErrIngressSnapshotNilCanonical`을 반환하고 builder는 수정 가능한 open 상태를 유지한다.

Before (`ingress_snapshot.go:183-191`):

```go
if err := b.requireOpen("Build"); err != nil {
    return nil, err
}
snap := &IngressSnapshot{
    backings: b.backings,
    canonical: b.canonicalHdl,
}
```

After:

```go
if err := b.requireOpen("Build"); err != nil {
    return nil, err
}
if !b.validateHandle(b.canonicalHdl) {
    return nil, ErrIngressSnapshotNilCanonical
}
```

- 수정 파일 및 체크리스트:
  - [ ] `packages/go/streamgate/ingress_snapshot.go`: stable nil-canonical sentinel과 Build precondition을 추가한다.
  - [ ] canonical 없이 Build하던 test setup을 실제 canonical association으로 보정하고 zero-length canonical 성공을 유지한다.
- 테스트 작성: `packages/go/streamgate/ingress_snapshot_test.go`의 `TestIngressSnapshot_BuildRequiresCanonical`에서 미설정 Build error, 이후 canonical 설정/Build 성공, zero-length canonical 성공을 검증한다.
- 중간 검증: `go test -count=1 ./packages/go/streamgate -run 'TestIngressSnapshot_(BuildRequiresCanonical|ZeroLengthCanonical)$' -v` — 두 테스트가 실제 실행되고 PASS.

### [REVIEW_REVIEW_REVIEW_INGRESS_SNAPSHOT_LEDGER-2] Observable terminal mutation errors

- 문제: `packages/go/streamgate/ingress_snapshot.go:106-127`은 `requireOpen`과 overflow error를 모두 `BackingHandleZero`로 변환해 발급 mutation이 현재 terminal state를 노출하지 못한다.
- 해결 방법: `IssueBackingHandle(data []byte) (BackingHandle, error)`로 좁히고 open success는 `(hdl, nil)`, overflow는 `(zero, failLocked(...))`, built/closed/terminal은 `(zero, requireOpen error)`를 반환한다. 모든 같은-package caller를 컴파일 가능하게 갱신한다.

Before (`ingress_snapshot.go:106-117`):

```go
func (b *IngressSnapshotBuilder) IssueBackingHandle(data []byte) BackingHandle {
    if err := b.requireOpen("IssueBackingHandle"); err != nil {
        return BackingHandleZero
    }
    if size > b.maxBytes-b.retainedBytes {
        b.failLocked(ErrIngressSnapshotLimitExceeded)
        return BackingHandleZero
    }
```

After:

```go
func (b *IngressSnapshotBuilder) IssueBackingHandle(data []byte) (BackingHandle, error) {
    if err := b.requireOpen("IssueBackingHandle"); err != nil {
        return BackingHandle{}, err
    }
    if size > b.maxBytes-b.retainedBytes {
        return BackingHandle{}, b.failLocked(ErrIngressSnapshotLimitExceeded)
    }
```

- 수정 파일 및 체크리스트:
  - [ ] `packages/go/streamgate/ingress_snapshot.go`: 발급 API error channel과 state precedence를 구현한다.
  - [ ] `packages/go/streamgate/ingress_snapshot_test.go`: 모든 call site를 `(handle, err)` 계약으로 갱신하고 예상하지 않은 error를 즉시 실패시킨다.
- 테스트 작성: `TestIngressSnapshot_IssueBackingStateErrors`에서 overflow 직후 재발급=`ErrIngressSnapshotLimitExceeded`, Close 뒤=`ErrIngressSnapshotClosed`, Build 뒤=`ErrIngressSnapshotAlreadyBuilt`을 검증한다.
- 중간 검증: `go test -count=1 ./packages/go/streamgate -run 'TestIngressSnapshot_(IssueBackingStateErrors|OverflowPoisonsBuilderAndReleases)$' -v` — stable error variants가 실제 실행되고 PASS.

### [REVIEW_REVIEW_REVIEW_INGRESS_SNAPSHOT_LEDGER-3] Transfer identity and zero-state evidence

- 문제: `packages/go/streamgate/ingress_snapshot.go:193-197,245-256`은 종결 뒤 `typedNames` 또는 `retainedBytes`를 남기고 `accounted` map은 읽히지 않는다. `ingress_snapshot_test.go:265-325`는 pointer identity를 비교하지 않아 full-copy 회귀를 잡지 못한다.
- 해결 방법: shared accounting source를 `backings` 하나로 유지하고 dead `accounted`를 제거한다. 성공 Build/Close/failLocked가 builder-owned map/slice와 current retained ledger를 nil/zero로 정리하되 snapshot은 동일 backing pointer를 소유한다.

Before (`ingress_snapshot.go:193-197,251-256`):

```go
b.backings = nil
b.accounted = nil
b.typedViews = nil
b.canonicalHdl = BackingHandleZero
b.builderBuilt = true
```

After:

```go
b.backings = nil
b.typedViews = nil
b.typedNames = nil
b.canonicalHdl = BackingHandle{}
b.retainedBytes = 0
b.builderBuilt = true
```

- 수정 파일 및 체크리스트:
  - [ ] `packages/go/streamgate/ingress_snapshot.go`: dead `accounted` state를 제거하고 Build/Close/failLocked의 zero-state를 일치시킨다.
  - [ ] `packages/go/streamgate/ingress_snapshot_test.go`: build 전 backing pointer를 저장해 snapshot map의 동일 pointer와 비교하고 builder metadata/counter zero-state를 검증한다.
  - [ ] overflow/Close fixture에서 payload references와 current retained ledger가 함께 해제되는지 검증한다.
- 테스트 작성: `TestIngressSnapshot_BuildTransfersBackingOwnership`에 pointer identity/typedNames nil/retained=0 assertion을 추가하고 `TestIngressSnapshot_OverflowPoisonsBuilderAndReleases`, `TestIngressSnapshot_CloseBuilderReleasesMemory`에 zero-state assertion을 추가한다.
- 중간 검증: `go test -count=1 ./packages/go/streamgate -run 'TestIngressSnapshot_(BuildTransfersBackingOwnership|OverflowPoisonsBuilderAndReleases|CloseBuilderReleasesMemory)$' -v` — identity와 success/failure cleanup이 PASS.

## 수정 파일 요약

| 파일 | 항목 |
|---|---|
| `packages/go/streamgate/ingress_snapshot.go` | REVIEW_REVIEW_REVIEW_INGRESS_SNAPSHOT_LEDGER-1/2/3 |
| `packages/go/streamgate/ingress_snapshot_test.go` | REVIEW_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_(BuildRequiresCanonical|IssueBackingStateErrors|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 'NewIngressSnapshotBuilder|IssueBackingHandle|SetCanonicalWithHandle|AddTypedView|IngressSnapshot' packages/go --glob '*.go'` — 새 signature의 모든 same-package call site와 interface 경계가 의도한 범위로 닫힘.
- `git diff --check` — whitespace error 없음.

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