<!-- task=m-stream-evidence-gate-core/01_event_contract_types plan=6 tag=REVIEW_API -->

# Stream Gate 값 불변성과 검증 단일 경계 후속 구현 계획

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

- 아래 범위의 제품 코드와 테스트만 수정하고 모든 검증을 실제로 실행한다.
- 구현 마지막 단계에서 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션과 실제 stdout/stderr를 채운 뒤 active 파일을 그대로 두고 `review-ready`를 보고한다.
- 판정, log rename, `complete.log`, task archive는 code-review 전용이다.
- 선택된 Milestone `구현 잠금 > 결정 필요`가 실구현을 차단할 때만 review stub의 `사용자 리뷰 요청`에 정확한 연결 근거를 기록하고 중단한다.
- 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하거나 `USER_REVIEW.md`를 만들지 않는다.
- 환경·secret·서비스 차단, 일반 범위 변경, 후속 에이전트가 닫을 수 있는 evidence 공백은 사용자 리뷰 사유가 아니다.

## 배경

직전 리뷰에서 기존 회귀 명령은 통과했지만 public input pointer가 `FilterDecision` 내부 상태를 바꾸고, private provider-error event가 5개 원인 사슬을 허용하는 계약 위반이 재현됐다. 오류 반환 public constructor의 마지막 `Validate` gate와 forbidden field별 독립 회귀도 아직 완결되지 않았다. 이번 후속은 같은 `packages/go/streamgate` 값 계약과 회귀 파일 안에서 네 Required를 함께 닫는다.

## 사용자 리뷰 요청 흐름

선택된 Milestone의 `구현 잠금 > 결정 필요`만 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 active review stub에 기록한다. 구현 중 직접 사용자 prompt는 금지하며, 요청의 유효성 판정과 실제 `USER_REVIEW.md` 작성은 code-review가 소유한다.

## Archive Evidence Snapshot

- 입력 evidence:
  - `agent-task/m-stream-evidence-gate-core/01_event_contract_types/plan_local_G05_5.log`
  - `agent-task/m-stream-evidence-gate-core/01_event_contract_types/code_review_local_G05_5.log`
- 판정: `FAIL`
- Required:
  - provider-error `NormalizedEvent.Validate`가 `FailureCauseChain` 최대 4개 상한을 재검증하지 않는다.
  - `NewFilterDecision`이 caller-owned `*RecoveryIntent`를 그대로 보관해 반환 값의 immutability를 깨뜨린다.
  - filter/terminal의 오류 반환 public constructor가 완성된 값을 own `Validate`에 통과시키는 단일 gate를 사용하지 않는다.
  - forbidden payload 회귀가 여러 field를 한 case에 넣어 각 field 검사를 독립적으로 보호하지 못하고 정상 public variant coverage도 부족하다.
- 영향 파일:
  - `packages/go/streamgate/event.go`
  - `packages/go/streamgate/filter_contract.go`
  - `packages/go/streamgate/terminal.go`
  - `packages/go/streamgate/validation_regression_test.go`
- 검증 evidence:
  - formatter, proto generation, focused/package/transport tests, vet, import/public-symbol boundary, diff check는 통과했다.
  - public probe는 caller-owned intent 변경이 기존 decision을 무효화함을 재현했다.
  - same-package overlay probe는 valid cause 5개인 provider-error event가 `Validate`를 통과함을 재현했다.
- Roadmap carryover: 이 pair는 `event-contract`의 부분 구현을 닫지만 codec/host fixture까지 완료하지 않으므로 `Roadmap Targets`를 두지 않는다.

## 분석 결과

### 읽은 파일

- `packages/go/streamgate/event.go`
- `packages/go/streamgate/filter_contract.go`
- `packages/go/streamgate/terminal.go`
- `packages/go/streamgate/validation_regression_test.go`
- `agent-task/m-stream-evidence-gate-core/01_event_contract_types/plan_local_G05_5.log`
- `agent-task/m-stream-evidence-gate-core/01_event_contract_types/code_review_local_G05_5.log`
- `agent-ops/rules/project/domain/platform-common/rules.md`
- `agent-roadmap/current.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`
- `agent-contract/index.md`
- `agent-contract/outer/openai-compatible-api.md`
- `agent-spec/index.md`
- `agent-test/local/rules.md`
- `agent-test/local/platform-common-smoke.md`

`agent-spec/index.md`에는 `streamgate`와 직접 매칭되는 living spec이 없어 코드, 계약, SDD, 테스트를 현재 기준으로 사용한다.

### SDD 기준

- SDD: `agent-roadmap/sdd/knowledge-tool-optimization-extension/stream-evidence-gate-core/SDD.md`
- 상태: `[승인됨]`, SDD 잠금 `해제`, 사용자 리뷰 없음
- S03 / `commit-boundary`: staged response-start와 terminal evaluation의 기존 회귀를 전체 package test로 보존한다.
- S09 / `event-contract`: transport-agnostic typed value가 constructor와 `Validate`에서 같은 폐쇄 계약을 유지하도록 `REVIEW_API-2`, `REVIEW_API-3`을 구성한다.
- S21 / `event-contract`: raw-free failure cause가 최대 4단계임을 `REVIEW_API-1`의 4개 정상/5개 거부 evidence로 만든다.
- Evidence Map의 codec/host fixture는 dependent sibling 범위이므로 이번 PASS만으로 S09/S21 전체 완료를 주장하지 않는다.

### 테스트 환경 규칙

- `test_env=local`
- `agent-test/local/rules.md`: 존재하며 읽음
- 매칭 profile: `agent-test/local/platform-common-smoke.md`
- 적용: `make proto`, 대상 package fresh test, Edge/Node transport fresh test, `go vet`, deterministic symbol/import 검색, `git diff --check`
- full-cycle 실제 연결은 proto/wire/host 호출부를 바꾸지 않는 이번 값 계약 후속에는 적용하지 않는다.
- `<확인 필요>` 값과 외부 runner/service/secret 전제는 없다.
- Go test cache는 bug-fix 회귀를 가리지 않도록 `-count=1`로 비활성화한다.

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

- provider-error `NormalizedEvent` cause 4/5 경계: 기존 테스트 없음.
- `NewFilterDecision` input pointer isolation: 기존 테스트 없음.
- `FilterOutcome`/`RecoveryDirective` forbidden field별 독립 case: 기존 테스트는 일부 field를 한 case에 묶어 검사 삭제를 탐지하지 못함.
- filter/terminal public constructor 정상 variant의 반환 값 `Validate`: 일부 helper만 간접 검증하고 전체 variant matrix 없음.
- empty normalized payload, malformed terminal nested value, invalid staged response-start는 기존 회귀가 있으며 유지한다.

### 심볼 참조

- rename/remove 대상 없음.
- repo Go source에서 `iop/packages/go/streamgate` importer와 `streamgate.` 호출부는 없음.
- forbidden public constructor `NewReleaseResponseStartEvent`, `NewReleaseTerminalEvent`, `NewTerminalResult`는 계속 없어야 한다.

### 분할 판단

- split decision policy를 재평가했다.
- 이번 후속은 기존 `01_event_contract_types` 안의 한 Go package, 한 public value-contract, 한 regression 파일을 함께 닫는 단일 구현 단위다.
- API와 call-site rollout 경계가 없고, 새 dependency·도메인·외부 검증도 없다.
- production 수정과 회귀를 분리하면 bug fix가 테스트 없이 먼저 완료될 수 있으므로 같은 pair가 더 안전하다.
- 디렉터리 `01_event_contract_types`에는 encoded predecessor가 없다. dependent sibling은 이 pair 완료 뒤 runtime이 별도로 처리한다.

### 범위 결정 근거

- 포함: `packages/go/streamgate`의 세 production 파일과 `validation_regression_test.go`.
- 제외: `apps/**`, `proto/**`, `packages/go/config/**`, codec/host fixture, roadmap/SDD/contract/spec 문서, sibling task 파일.
- 기존 dirty worktree의 다른 변경은 사용자 소유이므로 수정하거나 정리하지 않는다.
- 새 dependency는 필요하지 않아 `go.mod` 변경도 없다.

### 최종 라우팅

- `evaluation_mode: isolated-reassessment`
- build:
  - closures: scope/context/verification/evidence/ownership/decision 모두 `true`
  - 근거: 단일 package의 네 파일과 재현 가능한 두 bug 및 deterministic local 검증으로 폐쇄됨
  - scores: `scope_coupling=1`, `state_concurrency=1`, `blast_irreversibility=1`, `evidence_diagnosis=1`, `verification_complexity=1`
  - 결과: `local`, `G05`, `PLAN-local-G05.md`
- review:
  - closures: scope/context/verification/evidence/ownership/decision 모두 `true`
  - 근거: constructor gate, cause bound, defensive copy, field별 regression을 동일 package evidence로 판정 가능
  - scores: `scope_coupling=1`, `state_concurrency=1`, `blast_irreversibility=1`, `evidence_diagnosis=2`, `verification_complexity=1`
  - 결과: `local`, `G06`, `CODE_REVIEW-local-G06.md`

## 구현 체크리스트

- [x] `REVIEW_API-1` provider-error `NormalizedEvent.Validate`에 failure cause 최대 4개 상한을 적용하고 4개 정상/5개 거부 회귀를 작성한다.
- [x] `REVIEW_API-2` `NewFilterDecision`의 `RecoveryIntent` 입력을 defensive copy하고 filter public constructor를 assemble-then-own-`Validate` 단일 gate로 통일하며 pointer isolation·variant 회귀를 작성한다.
- [x] `REVIEW_API-3` terminal public constructor를 assemble-then-own-`Validate` 단일 gate로 통일하고 outcome/directive forbidden field별 독립 회귀와 정상 constructor matrix를 완성한다.
- [x] 계획의 local 최종 검증 명령을 `-count=1` 기준으로 모두 실행하고 실제 stdout/stderr를 review stub에 기록한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [REVIEW_API-1] Provider-error cause chain 상한 폐쇄

- 문제: `packages/go/streamgate/event.go:540-575`의 provider-error branch는 nested cause를 순회 검증하지만 `Len() > MaxFailureCauses`를 거부하지 않아 private malformed event가 5개 원인을 보존한다.
- 해결 방법:

Before (`packages/go/streamgate/event.go:562-575`):

```go
if err := e.externalDesc.Validate(); err != nil {
	return errors.New("streamgate: provider error event external descriptor: " + err.Error())
}
if e.failureCauses.Len() > 0 {
	for i := 0; i < e.failureCauses.Len(); i++ {
```

After:

```go
if err := e.externalDesc.Validate(); err != nil {
	return errors.New("streamgate: provider error event external descriptor: " + err.Error())
}
if e.failureCauses.Len() > MaxFailureCauses {
	return errors.New("streamgate: provider error event failure cause chain exceeds maximum")
}
for i := 0; i < e.failureCauses.Len(); i++ {
```

- 수정 파일 및 체크리스트:
  - [x] `packages/go/streamgate/event.go`: provider-error nested chain length와 각 cause 검증을 같은 branch에서 강제
  - [ ] `packages/go/streamgate/validation_regression_test.go`: `TestNormalizedEventValidationEnforcesCauseBound` 작성
- 테스트 작성:
  - private provider-error event의 valid cause 4개는 통과하고 5개는 실패해야 한다.
  - invalid nested cause도 기존처럼 실패해야 한다.
- 중간 검증:
  - `go test -count=1 ./packages/go/streamgate -run '^TestNormalizedEventValidationEnforcesCauseBound$'`
  - 기대: PASS.

### [REVIEW_API-2] Filter value immutability와 constructor 단일 gate

- 문제:
  - `packages/go/streamgate/filter_contract.go:58-97`은 caller-owned `*RecoveryIntent`를 그대로 저장해 생성 뒤 외부 대입이 기존 decision을 바꾼다.
  - `packages/go/streamgate/filter_contract.go:58-98,204-229,371-416,540-568,646-699`의 오류 반환 constructor는 field별 검사 뒤 struct literal을 바로 반환해 own `Validate`와 이중 원본을 유지한다.
- 해결 방법:

Before (`packages/go/streamgate/filter_contract.go:82-97`):

```go
if intent != nil {
	if kind != FilterDecisionKindViolation {
		return FilterDecision{}, errors.New("streamgate: only violation decision may carry a recovery intent")
	}
	if err := intent.Validate(); err != nil {
		return FilterDecision{}, err
	}
}
return FilterDecision{
	kind:       kind,
	consumerID: c,
	filterID:   f,
	ruleID:     r,
	evidence:   evidence,
	intent:     intent,
}, nil
```

After:

```go
var intentCopy *RecoveryIntent
if intent != nil {
	cp := *intent
	intentCopy = &cp
}
decision := FilterDecision{
	kind:       kind,
	consumerID: c,
	filterID:   f,
	ruleID:     r,
	evidence:   evidence,
	intent:     intentCopy,
}
if err := decision.Validate(); err != nil {
	return FilterDecision{}, err
}
return decision, nil
```

- 같은 assemble→own-`Validate`→return 패턴을 `NewFilterOutcomeEvaluated`, `NewFilterOutcomeEvaluationError`, 세 `NewRecoveryDirective*`, `NewRecoveryIntent`, `NewSanitizedEvidence`에 적용한다.
- 수정 파일 및 체크리스트:
  - [ ] `packages/go/streamgate/filter_contract.go`: input pointer defensive copy와 오류 반환 constructor final gate 통일
  - [ ] `packages/go/streamgate/validation_regression_test.go`: `TestFilterDecisionConstructorCopiesRecoveryIntent` 작성
  - [ ] `packages/go/streamgate/validation_regression_test.go`: `TestFilterOutcomeValidationRejectsEachForbiddenField` 작성
  - [ ] `packages/go/streamgate/validation_regression_test.go`: `TestRecoveryDirectiveValidationRejectsEachForbiddenField` 작성
- 테스트 작성:
  - constructor에 넘긴 intent 변수를 zero value나 다른 valid intent로 덮어써도 decision과 evaluated outcome이 원래 상태로 `Validate`를 통과해야 한다.
  - outcome/directive는 forbidden field 하나만 주입한 table case마다 실패해야 한다.
  - evaluated/error/not-applicable/deferred와 exact/continuation/schema 정상 variant는 모두 `Validate`를 통과해야 한다.
- 중간 검증:
  - `go test -count=1 ./packages/go/streamgate -run 'Test(FilterDecisionConstructorCopiesRecoveryIntent|FilterOutcomeValidationRejectsEachForbiddenField|RecoveryDirectiveValidationRejectsEachForbiddenField)$'`
  - 기대: PASS.

### [REVIEW_API-3] Terminal constructor 단일 gate와 정상 variant matrix

- 문제: `packages/go/streamgate/terminal.go:135-165,294-319`의 `NewFailureCause`와 `NewExternalDescriptor`가 완성된 값을 own `Validate`에 통과시키지 않아 constructor와 nested validation이 별도 원본으로 남아 있다. filter/terminal public 정상 variant 전체를 확인하는 persistent test도 없다.
- 해결 방법:

Before (`packages/go/streamgate/terminal.go:158-165`):

```go
return FailureCause{
	stage:    s,
	code:     c,
	consumer: con,
	filter:   f,
	ruleID:   r,
}, nil
```

After:

```go
cause := FailureCause{
	stage:    s,
	code:     c,
	consumer: con,
	filter:   f,
	ruleID:   r,
}
if err := cause.Validate(); err != nil {
	return FailureCause{}, err
}
return cause, nil
```

- `NewExternalDescriptor`도 같은 패턴으로 바꾸고, item 2의 filter constructor 정상 variant와 함께 persistent matrix로 검증한다.
- 수정 파일 및 체크리스트:
  - [ ] `packages/go/streamgate/terminal.go`: `NewFailureCause`, `NewExternalDescriptor` final `Validate` gate
  - [ ] `packages/go/streamgate/validation_regression_test.go`: `TestFilterAndTerminalConstructorsProduceValidValues` 작성
- 테스트 작성:
  - failure cause required/optional token 조합, external descriptor required/optional field 조합이 constructor와 `Validate`를 모두 통과해야 한다.
  - filter decision/outcome/directive/intent/evidence의 정상 public variant도 같은 test에서 확인한다.
  - invalid input은 각 public constructor가 오류를 반환해야 한다.
- 중간 검증:
  - `go test -count=1 ./packages/go/streamgate -run '^TestFilterAndTerminalConstructorsProduceValidValues$'`
  - 기대: PASS.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `packages/go/streamgate/event.go` | `REVIEW_API-1` |
| `packages/go/streamgate/filter_contract.go` | `REVIEW_API-2` |
| `packages/go/streamgate/terminal.go` | `REVIEW_API-3` |
| `packages/go/streamgate/validation_regression_test.go` | `REVIEW_API-1`, `REVIEW_API-2`, `REVIEW_API-3` |

## 최종 검증

환경: `local`, repo root `/config/workspace/iop`. Go test cache는 허용하지 않는다.

1. `gofmt -d packages/go/streamgate/event.go packages/go/streamgate/filter_contract.go packages/go/streamgate/terminal.go packages/go/streamgate/validation_regression_test.go`
   - 기대: 출력 없음.
2. `make proto`
   - 기대: exit 0, `proto/**` 추가 변경 없음.
3. `go test -count=1 ./packages/go/streamgate -run 'Test(NormalizedEventValidationEnforcesCauseBound|FilterDecisionConstructorCopiesRecoveryIntent|FilterOutcomeValidationRejectsEachForbiddenField|RecoveryDirectiveValidationRejectsEachForbiddenField|FilterAndTerminalConstructorsProduceValidValues)$'`
   - 기대: 다섯 후속 회귀 PASS.
4. `go test -count=1 ./packages/go/streamgate`
   - 기대: package 전체 PASS.
5. `go test -count=1 ./apps/edge/internal/transport ./apps/node/internal/transport`
   - 기대: 두 package PASS.
6. `go vet ./packages/go/streamgate`
   - 기대: 출력 없이 exit 0.
7. `go list -f '{{range .Imports}}{{println .}}{{end}}' ./packages/go/streamgate | LC_ALL=C sort`
   - 기대: `context`, `errors`, `strconv`, `time`.
8. `if rg -n --sort path '"iop/(apps/|proto/gen/|packages/go/config)"' packages/go/streamgate --glob '!**/*_test.go'; then exit 1; else echo "PASS: no forbidden imports"; fi`
   - 기대: `PASS: no forbidden imports`.
9. `if rg -n --sort path '^func NewRelease(ResponseStart|Terminal)Event|^func NewTerminalResult' packages/go/streamgate; then exit 1; else echo "PASS: forbidden public constructors absent"; fi`
   - 기대: `PASS: forbidden public constructors absent`.
10. `git diff --check -- packages/go/streamgate`
    - 기대: 출력 없음.

모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
