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

# Stream Gate 값 계약 폐쇄와 회귀 테스트 후속 구현 계획

## 구현 에이전트 규칙

- runtime이 encoded predecessor dependency를 확인한 뒤 dispatch한다. `agent-task/**`와 `agent-task/archive/**`를 검색하지 않는다.
- 아래 production/test 파일만 수정하고 구현 체크리스트와 검증 계약을 따른다.
- review stub의 구현 에이전트 소유 필드만 채운다.
- `review-ready`를 보고하고 종료한다. review, archive, terminal file 작성, 다음 agent 시작은 금지한다.

## 배경

직전 구현은 형식·compile·transport 검증을 통과했지만 constructor와 nested `Validate` 경계가 달라 malformed 값 18개를 수용했다. 최신 `plan` 스킬의 bug-fix 정책에 맞춰 네 Required와 최소 persistent regression test를 같은 production sibling에서 닫는다.

## Archive Evidence Snapshot

- 교체 대상 미구현 pair:
  - `agent-task/m-stream-evidence-gate-core/01_event_contract_types/plan_cloud_G05_4.log`
  - `agent-task/m-stream-evidence-gate-core/01_event_contract_types/code_review_cloud_G05_4.log`
- FAIL 근거 pair:
  - `agent-task/m-stream-evidence-gate-core/01_event_contract_types/plan_local_G05_3.log`
  - `agent-task/m-stream-evidence-gate-core/01_event_contract_types/code_review_local_G06_3.log`
- 판정: `FAIL`
- Required:
  - text/reasoning/tool normalized public constructor가 완성된 값을 `NormalizedEvent.Validate`에 통과시키지 않아 빈 payload를 허용한다.
  - filter outcome/directive의 반대 payload, filter/intent/evidence의 nested stable token, sanitized evidence의 closed enum이 `Validate`에서 거부되지 않는다.
  - failure cause/descriptor의 required stable token과 terminal result의 descriptor/cause chain이 재귀 검증되지 않는다.
  - non-terminal `EvidenceBatch`의 staged response-start가 `ResponseStart.Validate`를 거치지 않아 invalid HTTP status를 보존한다.
- 검증 evidence:
  - formatter, proto generation, package/transport test, vet, import/public-symbol boundary는 PASS했다.
  - public probe는 빈 normalized payload 3개를, same-package overlay probe는 반대 payload·unknown enum·nested token/descriptor/cause·invalid staged status 15개를 재현했다.
- 재작성 사유: 교체 대상 pair는 repository regression test를 dependent sibling에 미뤘다. 현재 스킬은 bug fix의 최소 정상·경계 회귀 테스트를 production 변경과 같은 sibling에 두도록 요구한다.
- 역할 identity preflight 예외: 사용자가 미할당/동일 model identity 조건을 무시하고 진행하라고 명시했다. 기존 runtime ledger는 수정하지 않고 새 review metadata도 runtime 기본 상태로 둔다.
- Roadmap carryover: 이 task는 `event-contract` production 타입의 부분 구현이다. broader public API unit matrix는 `03+01_event_contract_unit_tests`, codec/host closure는 `04+01,03_codec_contract_fixtures`가 소유하므로 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-roadmap/sdd/knowledge-tool-optimization-extension/stream-evidence-gate-core/SDD.md`의 S03, S09, S21
  - `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/stream-evidence-gate-core.md`의 `event-contract`
  - `agent-contract/outer/openai-compatible-api.md`의 endpoint별 단일 오류와 내부 cause 비노출 경계
- 필수 불변조건:
  - 오류를 반환하는 public constructor는 typed value를 완성한 뒤 자신의 `Validate`를 호출하고 그 결과만 반환한다.
  - 모든 discriminated `Validate`는 kind별 required payload와 모든 forbidden payload를 함께 검사한다.
  - required/optional `StableToken`, closed enum, descriptor, cause chain은 private field로 조립된 값에서도 constructor와 같은 grammar·bound로 재검증된다.
  - staged response-start는 terminal 여부와 무관하게 `ResponseStart.Validate`를 먼저 통과한 뒤 commit-state 조합을 검사한다.
- 테스트:
  - `validation_regression_test.go`는 `package streamgate` same-package test로 작성해 private malformed state와 public constructor를 모두 검증한다.
  - 이 파일은 dependent `03+01_event_contract_unit_tests`의 `event_test.go`, `filter_contract_test.go`, `terminal_test.go`와 write-set이 겹치지 않는다.
- 제외:
  - dependent sibling이 소유한 `event_test.go`, `filter_contract_test.go`, `terminal_test.go`
  - codec/host fixture, Edge/Node host adoption
  - `apps/**`, `proto/**`, `packages/go/config/**`
  - roadmap, SDD, contract, spec 문서
  - coordinator, registry, arbiter, recovery dispatch 구현

## 구현 체크리스트

- [ ] `REVIEW_API-1` text/reasoning/tool normalized constructor를 assemble-then-`Validate` 경계로 통일하고 정상·빈 payload 회귀 테스트를 작성한다.
- [ ] `REVIEW_API-2` filter outcome/directive의 반대 payload와 filter/intent/evidence의 nested token·closed enum을 재검증하고 private malformed state 회귀 테스트를 작성한다.
- [ ] `REVIEW_API-3` failure cause/descriptor/terminal result가 required token과 bounded cause chain 전체를 재귀 검증하게 하고 정상·경계 회귀 테스트를 작성한다.
- [ ] `REVIEW_API-4` staged response-start validation을 terminal 여부와 무관한 lifecycle 단일 경계로 옮기고 HTTP status/commit-state 회귀 테스트를 작성한다.
- [ ] 계획에 명시한 local 검증 명령을 실행하고 실제 stdout/stderr를 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 채우고 review-ready를 보고한 뒤 현재 실행을 종료한다. code-review를 실행하거나 다음 에이전트를 시작하지 않는다.

### [REVIEW_API-1] Normalized constructor-to-Validate 단일화

- 문제: `packages/go/streamgate/event.go:240-309`의 `NewTextDeltaEvent`, `NewReasoningDeltaEvent`, `NewToolCallFragmentEvent`가 값을 바로 반환해 `NormalizedEvent.Validate`의 non-empty payload 계약을 우회한다.
- 해결 방법:
  - 세 constructor는 disposition을 포함한 `NormalizedEvent`를 먼저 조립한다.
  - 조립한 값의 `Validate`를 호출하고 실패 시 zero value와 오류를 반환한다.
  - constructor별 중복 검사는 제거하거나 `Validate`와 같은 precondition으로만 남긴다.
- Before (`packages/go/streamgate/event.go:251-258`):

```go
return NormalizedEvent{
	kind:        EventKindTextDelta,
	channel:     channel,
	timestamp:   ts,
	disposition: disp,
	textDelta:   text,
}, nil
```

- After:

```go
ev := NormalizedEvent{
	kind:        EventKindTextDelta,
	channel:     channel,
	timestamp:   ts,
	disposition: disp,
	textDelta:   text,
}
if err := ev.Validate(); err != nil {
	return NormalizedEvent{}, err
}
return ev, nil
```

- 수정 파일 및 체크리스트:
  - [ ] `packages/go/streamgate/event.go`: 세 normalized delta constructor를 assemble-then-`Validate`로 통일
  - [ ] `packages/go/streamgate/validation_regression_test.go`: `TestNormalizedConstructorsRejectEmptyPayload` 작성
- 테스트 작성:
  - 빈 text, reasoning, tool arguments는 모두 오류여야 한다.
  - non-empty text/reasoning/tool fragment는 정상 생성되고 `Validate`를 통과해야 한다.

### [REVIEW_API-2] Filter discriminant와 nested token 폐쇄

- 문제:
  - `packages/go/streamgate/filter_contract.go:249-273`의 `FilterOutcome.Validate`는 kind의 반대 `decision`/`errorCode`를 거부하지 않는다.
  - `packages/go/streamgate/filter_contract.go:398-427`의 `RecoveryDirective.Validate`는 exact/continuation/schema 반대 field를 거부하지 않는다.
  - `packages/go/streamgate/filter_contract.go:101-128,508-527,636-661`은 nested required token 문법과 sanitized evidence event/outcome closed enum을 재검증하지 않는다.
- 해결 방법:
  - required token 재검증 helper를 constructor의 `validateStableToken` 원본 위에 두고 optional helper와 구분한다.
  - `FilterDecision`, `FilterOutcome`, `RecoveryDirective`, `RecoveryIntent`, `SanitizedEvidence`의 required token을 grammar까지 재검증한다.
  - `FilterOutcome`과 `RecoveryDirective`는 kind별 required payload 외 모든 반대 payload가 zero인지 검사한다.
  - `SanitizedEvidence.Validate`는 `EventKind.Validate`와 `FilterOutcomeKind.Validate`를 호출한다.
  - 오류를 반환하는 constructor는 완성된 값의 `Validate`를 마지막 gate로 호출한다.
- Before (`packages/go/streamgate/filter_contract.go:256-269`):

```go
switch o.kind {
case FilterOutcomeKindEvaluated:
	if o.decision == nil {
		return errors.New("streamgate: evaluated filter outcome requires a decision")
	}
	if err := o.decision.Validate(); err != nil {
		return err
	}
case FilterOutcomeKindEvaluationError:
	if o.errorCode.value == "" {
		return errors.New("streamgate: evaluation error filter outcome requires an error code")
	}
case FilterOutcomeKindNotApplicableForEpoch, FilterOutcomeKindDeferredByRequirement:
	// No additional fields required.
}
```

- After:

```go
switch o.kind {
case FilterOutcomeKindEvaluated:
	// require decision, forbid errorCode, recursively validate decision
case FilterOutcomeKindEvaluationError:
	// forbid decision, validate required errorCode grammar
case FilterOutcomeKindNotApplicableForEpoch, FilterOutcomeKindDeferredByRequirement:
	// forbid both decision and errorCode
}
```

- 수정 파일 및 체크리스트:
  - [ ] `packages/go/streamgate/terminal.go`: required/optional nested stable-token helper를 같은 grammar 원본 위에 정리
  - [ ] `packages/go/streamgate/filter_contract.go`: filter/recovery/evidence exact payload·token·enum validation
  - [ ] `packages/go/streamgate/validation_regression_test.go`: `TestFilterValueValidationRejectsMalformedPrivateState` 작성
- 테스트 작성:
  - outcome/directive 반대 payload, invalid required token, unknown event/outcome은 직접 조립한 private value의 `Validate`에서 모두 거부되어야 한다.
  - 각 public constructor의 정상 variant는 `Validate`를 통과해야 한다.

### [REVIEW_API-3] Terminal nested value 재귀 검증

- 문제:
  - `packages/go/streamgate/terminal.go:157-173`은 required stage/code를 non-empty로만 확인한다.
  - `packages/go/streamgate/terminal.go:311-324`는 required error type/message를 non-empty로만 확인한다.
  - `packages/go/streamgate/terminal.go:450-472`는 error descriptor와 failure cause chain의 최대 길이·각 cause를 검사하지 않는다.
- 해결 방법:
  - `FailureCause.Validate`의 stage/code와 `ExternalDescriptor.Validate`의 errorType/message는 required helper로 재검증한다.
  - optional consumer/filter/rule/code/param은 optional helper로 재검증한다.
  - `TerminalResult.Validate`의 error branch는 descriptor, `Len() <= MaxFailureCauses`, 모든 cause의 `Validate`를 검사한다.
  - success branch의 descriptor/cause 금지와 error branch의 descriptor 필수 조건은 같은 `Validate`에 둔다.
- Before (`packages/go/streamgate/terminal.go:460-472`):

```go
if tr.err && tr.externalDesc == nil {
	return errors.New("streamgate: terminal result error requires an external descriptor")
}
if tr.success && tr.externalDesc != nil {
	return errors.New("streamgate: terminal result success must not have an external descriptor")
}
if tr.success && tr.failureCauses.Len() > 0 {
	return errors.New("streamgate: terminal result success must not have failure causes")
}
return nil
```

- After:

```go
if tr.err {
	if tr.externalDesc == nil {
		return errors.New("streamgate: terminal result error requires an external descriptor")
	}
	if err := tr.externalDesc.Validate(); err != nil {
		return err
	}
	if tr.failureCauses.Len() > MaxFailureCauses {
		return errors.New("streamgate: terminal result failure cause chain exceeds maximum")
	}
	// Validate every nested FailureCause.
}
```

- 수정 파일 및 체크리스트:
  - [ ] `packages/go/streamgate/terminal.go`: failure cause, external descriptor, terminal result recursive validation
  - [ ] `packages/go/streamgate/validation_regression_test.go`: `TestTerminalValueValidationRejectsMalformedPrivateState` 작성
- 테스트 작성:
  - invalid required stage/code/type/message, malformed nested descriptor/cause와 private 5-entry chain은 거부되어야 한다.
  - valid 4-entry error chain과 descriptor 없는 success terminal은 통과해야 한다.

### [REVIEW_API-4] Staged response-start lifecycle validation

- 문제: `packages/go/streamgate/filter_contract.go:792-802`의 non-terminal branch는 staged start의 channel/timestamp만 확인해 `ResponseStart.Validate`의 HTTP status 계약을 우회한다.
- 해결 방법:
  - `validateEvidenceBatchLifecycle` 초입에서 `stagedStart != nil`이면 terminal 여부와 무관하게 `stagedStart.Validate()`를 호출한다.
  - 그 뒤 `transport_uncommitted`에서만 staged start를 허용하고 stream-open/terminal-committed 조합을 거부한다.
  - terminal branch의 중복 nested validation을 제거해 constructor와 `EvidenceBatch.Validate`가 같은 helper를 사용하게 한다.
- Before (`packages/go/streamgate/filter_contract.go:792-802`):

```go
if stagedStart != nil && !terminalFlag {
	if stagedStart.channel == "" {
		return errors.New("streamgate: staged response start channel is required")
	}
	if stagedStart.timestamp.IsZero() {
		return errors.New("streamgate: staged response start timestamp is required")
	}
	if commitState != CommitStateTransportUncommitted {
		return errors.New("streamgate: staged response start requires transport_uncommitted state")
	}
}
```

- After:

```go
if stagedStart != nil {
	if err := stagedStart.Validate(); err != nil {
		return errors.New("streamgate: evidence batch staged response start: " + err.Error())
	}
	if commitState != CommitStateTransportUncommitted {
		return errors.New("streamgate: staged response start requires transport_uncommitted state")
	}
}
```

- 수정 파일 및 체크리스트:
  - [ ] `packages/go/streamgate/filter_contract.go`: staged start validation과 commit-state 검사를 shared lifecycle helper에 단일화
  - [ ] `packages/go/streamgate/validation_regression_test.go`: `TestEvidenceBatchValidationRejectsMalformedStagedStart` 작성
- 테스트 작성:
  - private status 99/600 staged start는 `NewEvidenceBatch`와 direct `EvidenceBatch.Validate`에서 거부되어야 한다.
  - status 100/599와 `transport_uncommitted` 정상 조합은 통과하고 staged start가 있는 `stream_open`은 거부되어야 한다.

## 수정 파일 요약

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

## 최종 검증

환경: `local`, repo root `/config/workspace/iop`. Go test cache가 constructor/validation 회귀를 가리지 않도록 `-count=1`을 사용한다.

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. 생성물 추가 변경 없음.
3. `go test -count=1 ./packages/go/streamgate -run 'Test(NormalizedConstructorsRejectEmptyPayload|FilterValueValidationRejectsMalformedPrivateState|TerminalValueValidationRejectsMalformedPrivateState|EvidenceBatchValidationRejectsMalformedStagedStart)$'`
   - 기대: 네 regression test 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 `ok`.
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`
    - 기대: 출력 없음.

검증 결과를 review stub에 기록하고 review-ready를 보고한 뒤 현재 실행을 종료한다. code-review를 실행하거나 다음 에이전트를 시작하지 않는다.
