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

# Stream Gate Event Contract 불변조건 후속 구현 계획

## 구현 에이전트 규칙

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

## 배경

두 번째 구현은 compile과 transport 회귀를 통과했지만 success/error terminal, filter variant, raw-free scalar, recursive snapshot 불변조건을 constructor와 `Validate` 양쪽에서 닫지 못했다. 승인된 SDD S09/S21과 archived FAIL의 Required finding을 같은 세 production 파일에서 교정한다.

## Archive Evidence Snapshot

- 이전 pair:
  - `agent-task/m-stream-evidence-gate-core/01_event_contract_types/plan_local_G04_1.log`
  - `agent-task/m-stream-evidence-gate-core/01_event_contract_types/code_review_local_G05_1.log`
- 역할 provenance:
  - Plan: `GPT-5`
  - Implementation: `Claude-opus-4-6`
  - Review: `pending`
  - 리뷰 시작 예외: 사용자가 runtime-owned `Review: pending` identity preflight를 무시하고 진행하라고 명시했다. 기존 ledger는 수정하지 않았다.
- 판정: `FAIL`
- Required:
  - success terminal event가 descriptor/cause를 요구한 뒤 invalid success `TerminalResult`를 만들고, release tool arguments가 손실되며 terminal release가 `success=false`를 허용한다.
  - filter decision/outcome/evidence가 closed enum과 variant별 필수/금지 payload, bounded stable token/fingerprint를 완전 검증하지 않는다.
  - continuation/schema directive, recovery reason, cause identity, external message/param이 자유 문자열이라 raw-free 경계를 타입으로 강제하지 않는다.
  - `EvidenceBatch`의 look-behind가 channel별이 아니고 nested header/descriptor/cause를 재귀 복제하지 않으며 element/channel/commit/terminal 조합을 검증하지 않는다.
  - response status, event/release kind-payload exclusivity와 nested validation이 불완전하고 공개 mutable commit-state map이 exact state 집합을 바꿀 수 있다.
- Nit: failure-cause index 진단은 `string(rune('0'+i))` 대신 `strconv.Itoa(i)`를 사용한다.
- 검증 evidence: formatter, proto generation, streamgate compile, Edge/Node transport regression, `go vet`, forbidden import 검사는 PASS했다. package 직접 단위 테스트는 dependent `03+01_event_contract_unit_tests`가 소유한다.
- 영향 파일: `packages/go/streamgate/event.go`, `packages/go/streamgate/filter_contract.go`, `packages/go/streamgate/terminal.go`
- Roadmap carryover: 이 pair는 `event-contract`의 production 타입 부분만 교정한다. 직접 단위 테스트는 `03+01_event_contract_unit_tests`, S09/S21 fake codec·host·import-boundary closure는 `02+01+03_codec_contract_fixtures`가 소유한다.

## 구현 범위

- 수정 파일:
  - `packages/go/streamgate/event.go`
  - `packages/go/streamgate/filter_contract.go`
  - `packages/go/streamgate/terminal.go`
- 적용 기준:
  - `agent-roadmap/sdd/knowledge-tool-optimization-extension/stream-evidence-gate-core/SDD.md`의 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 비노출 경계
- 필수 불변조건:
  - normalized event, release event, terminal result는 kind별 typed payload 하나만 가지며 모든 public constructor가 공통 `Validate`를 통과한다.
  - stable id/code/template은 최대 128-byte ASCII token value, evidence fingerprint는 32-byte digest value로 표현해 raw prompt/output/provider body/auth 자유 문자열 경로를 제거한다.
  - mutable map/slice를 가진 event/start/batch는 constructor와 accessor에서 재귀 deep-copy한다.
  - commit state와 decision/outcome/recovery/event enum은 immutable switch 기반 closed set이다.
- 호출부:
  - 현재 product source/test에서 세 파일 밖의 constructor call site는 없다.
  - `event.go` 내부 `AsTerminal`/`AsProviderError`와 세 파일 내부 constructor/accessor를 새 API에 맞춘다.
- 테스트 작성: 이 pair에서는 skip한다. `03+01_event_contract_unit_tests`가 정상/경계 regression을 작성하며, 현재 pair는 compile·vet·transport·import 검증만 수행한다.
- 제외:
  - `*_test.go`, codec/host fake fixture, Edge/Node host adoption
  - `apps/**`, `proto/**`, `packages/go/config/**`
  - roadmap, SDD, contract, spec 문서
  - coordinator, registry, arbiter, recovery dispatch 구현

## 구현 체크리스트

- [ ] `REVIEW_API-1` normalized/release/terminal payload를 배타적 typed variant로 바꾸고 success와 provider-error constructor를 분리해 모든 constructor가 공통 validation을 통과하게 한다.
- [ ] `REVIEW_API-2` filter decision/outcome/recovery/evidence와 terminal descriptor/cause를 closed enum 및 bounded typed scalar로 교정해 raw-free 경계를 강제한다.
- [ ] `REVIEW_API-3` `EvidenceBatch`를 channel별 pending/look-behind 재귀 snapshot으로 만들고 event/start/commit/terminal 조합과 모든 nested value를 검증한다.
- [ ] 계획에 명시한 local 검증 명령을 실행하고 실제 stdout/stderr를 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 채우고 review-ready를 보고한 뒤 현재 실행을 종료한다. code-review를 실행하거나 다음 에이전트를 시작하지 않는다.

### [REVIEW_API-1] Event, release, terminal variant 폐쇄

- 문제:
  - `packages/go/streamgate/event.go:105-132,188-213`은 response status의 HTTP 범위를 검증하지 않는다.
  - `packages/go/streamgate/event.go:160-185,353-381`은 여러 payload를 한 struct에 두고 반대 payload와 nested descriptor/cause를 검증하지 않는다.
  - `packages/go/streamgate/event.go:290-318,443-456`은 success terminal에 error descriptor/cause를 넣어 invalid `TerminalResult`를 만든다.
  - `packages/go/streamgate/event.go:556-591`은 tool arguments를 잃고 terminal release에 `success=false`를 허용한다.
  - `packages/go/streamgate/terminal.go:246-310`은 boolean 조합 constructor가 반환 전에 descriptor/cause와 success/error 상호 배제를 완전 검증하지 않는다.
- 해결 방법:
  - response status는 constructor와 `Validate`에서 `100..599`만 허용한다.
  - `NormalizedEvent`는 response-start/text/reasoning/tool/success-terminal/provider-error payload 중 정확히 하나만 가진 private discriminated union으로 바꾸고 각 payload의 `Validate`를 호출한다.
  - `NewTerminalEvent(channel, timestamp)`와 `NewSuccessTerminalResult`는 descriptor/cause가 없는 success만 만든다. `NewProviderErrorEvent`와 `NewErrorTerminalResult`는 validated descriptor와 bounded cause를 요구한다.
  - `ReleaseEvent`는 safe release candidate인 text/reasoning/tool fragment만 허용한다. response-start는 `CommitResponseStart`, terminal은 `CommitTerminal` 경계를 사용한다.
  - tool release constructor/accessor는 `Arguments`를 입력부터 출력까지 보존한다.
  - 모든 public constructor는 value를 조립한 뒤 자신의 `Validate`를 호출하고 성공한 값만 반환한다.
- Before (`packages/go/streamgate/event.go:288-318`):

```go
func NewTerminalEvent(channel string, desc *ExternalDescriptor, causes FailureCauseChain, ts time.Time) (NormalizedEvent, error) {
	// success event가 error descriptor와 failure causes를 보존한다.
}
```

- After:

```go
func NewTerminalEvent(channel string, ts time.Time) (NormalizedEvent, error)
func NewProviderErrorEvent(
	channel string,
	desc ExternalDescriptor,
	causes FailureCauseChain,
	ts time.Time,
) (NormalizedEvent, error)

func NewSuccessTerminalResult(channel string, occurredAt time.Time) (TerminalResult, error)
func NewErrorTerminalResult(
	channel string,
	desc ExternalDescriptor,
	causes FailureCauseChain,
	occurredAt time.Time,
) (TerminalResult, error)
```

- 수정 파일 및 체크리스트:
  - [ ] `packages/go/streamgate/event.go`: typed event/release variants, HTTP status bound, tool arguments, recursive copy, constructor-to-`Validate`
  - [ ] `packages/go/streamgate/terminal.go`: separate success/error result constructors, descriptor/cause validation, strict mutual exclusion
- 테스트 작성: skip. `03+01_event_contract_unit_tests`가 status boundary, kind-payload exclusivity, terminal success/error와 tool argument round-trip regression을 작성한다.

### [REVIEW_API-2] Closed filter variants와 raw-free typed scalar

- 문제:
  - `packages/go/streamgate/filter_contract.go:46-96`은 unknown decision kind와 evidence/intent 조합을 허용하고 `Validate`가 nested value를 다시 검사하지 않는다.
  - `packages/go/streamgate/filter_contract.go:154-220`은 outcome 반대 payload를 금지하지 않는다.
  - `packages/go/streamgate/filter_contract.go:270-404`는 safe prefix, schema patch, reason을 자유 문자열로 보존한다.
  - `packages/go/streamgate/filter_contract.go:442-512`는 unknown event/outcome과 unbounded descriptor/fingerprint를 허용한다.
  - `packages/go/streamgate/terminal.go:48-82,184-216`은 cause identity와 external message/param을 자유 문자열로 받는다.
- 해결 방법:
  - decision/outcome/recovery strategy/directive/event/commit enum은 private lookup map이 아닌 switch로 known 값만 허용한다.
  - decision은 `violation`만 optional recovery intent를 가질 수 있고 `pass|observe|fatal|replacement`는 intent를 금지한다. 모든 decision은 bounded identity와 valid evidence를 요구한다.
  - outcome은 `evaluated`만 decision, `evaluation_error`만 stable error code를 가지며 `not_applicable_for_epoch|deferred_by_requirement`는 둘 다 금지한다.
  - 공용 private-value type으로 최대 128-byte ASCII `StableToken`을 만들고 consumer/filter/rule/stage/code/reason/template/param reference에 사용한다. grammar는 첫 글자 `[a-z0-9]`, 이후 `[a-z0-9._:-]`만 허용하고 각 constructor가 required/optional을 명시한다. accessor는 string만 반환한다.
  - fingerprint는 arbitrary string 대신 `[32]byte` digest value를 저장하고 zero digest를 거부한다.
  - continuation directive는 cursor와 bounded snapshot/cursor reference만, schema directive는 stable schema/patch descriptor reference만 보존한다. `safePrefix`와 `schemaPatch` 원문 필드를 제거한다.
  - external descriptor는 stable type/code와 safe template id, optional sanitized param descriptor만 보존한다. endpoint host가 template id를 외부 message로 직렬화한다.
  - `NewFailureCauseChain`의 index 오류는 `strconv.Itoa(i)`를 사용한다.
- Before (`packages/go/streamgate/filter_contract.go:270-278`):

```go
type RecoveryDirective struct {
	kind        RecoveryDirectiveKind
	requestID   string
	cursor      int
	safePrefix  string
	schemaPatch string
}
```

- After:

```go
type StableToken struct {
	value string
}

type RecoveryDirective struct {
	kind          RecoveryDirectiveKind
	requestRef    StableToken
	cursor        int
	snapshotRef   StableToken
	schemaRef     StableToken
	patchCode     StableToken
}
```

- 수정 파일 및 체크리스트:
  - [ ] `packages/go/streamgate/filter_contract.go`: closed decision/outcome/recovery enums, variant exclusivity, fixed fingerprint, typed directives/reason/evidence
  - [ ] `packages/go/streamgate/terminal.go`: shared bounded token/template/param contract, cause/descriptor validation, `strconv.Itoa`
- 테스트 작성: skip. `03+01_event_contract_unit_tests`가 unknown enum, intent/payload exclusivity, over-limit token, fixed fingerprint, raw-free directive/descriptor boundary를 작성한다.

### [REVIEW_API-3] Channel별 recursive EvidenceBatch snapshot

- 문제:
  - `packages/go/streamgate/filter_contract.go:547-599`은 committed look-behind를 단일 slice로 보존하고 nested event/start를 struct copy만 한다.
  - `packages/go/streamgate/filter_contract.go:602-652`는 event, channel key, staged start, commit state, terminal flag를 검증하지 않고 accessor도 nested backing을 공유한다.
  - `packages/go/streamgate/terminal.go:29-37`은 caller가 수정 가능한 exported map과 그 alias를 commit-state 원본으로 사용한다.
- 해결 방법:
  - `committedLookBehind`를 `map[string][]NormalizedEvent`로 바꾸고 pending과 같은 channel key/event channel 일치 규칙을 적용한다.
  - `ResponseStart`, `NormalizedEvent`, `FailureCauseChain`, `ExternalDescriptor`에 private clone helper를 두고 headers, descriptor, causes를 재귀 복제한다. batch constructor와 모든 accessor가 이 helper만 사용한다.
  - batch constructor는 events/pending/look-behind 각 element와 non-empty channel key, staged start를 검증한다.
  - terminal flag가 true면 batch event에 terminal/provider-error가 정확히 하나 있어야 하고 false면 없어야 한다. `terminal_committed`는 terminal flag가 필요하며 staged start는 `transport_uncommitted`에서만 허용한다.
  - exported `ValidCommitStates` map을 제거하고 `CommitState.Validate()`의 switch를 constructor와 batch validation에서 사용한다.
  - validation이 끝난 뒤에만 deep-copied batch를 반환하고 accessor 반환값을 변이해도 원본이 바뀌지 않게 한다.
- Before (`packages/go/streamgate/filter_contract.go:547-555`):

```go
type EvidenceBatch struct {
	events              []NormalizedEvent
	channelPending      map[string][]NormalizedEvent
	committedLookBehind []NormalizedEvent
	stagedStart         *ResponseStart
	terminalFlag        bool
	commitState         CommitState
	capturedAt          time.Time
}
```

- After:

```go
type EvidenceBatch struct {
	events              []NormalizedEvent
	channelPending      map[string][]NormalizedEvent
	committedLookBehind map[string][]NormalizedEvent
	stagedStart         *ResponseStart
	terminalFlag        bool
	commitState         CommitState
	capturedAt          time.Time
}
```

- 새 import (`packages/go/streamgate/terminal.go`):

```go
import (
	"context"
	"errors"
	"strconv"
	"time"
)
```

- 수정 파일 및 체크리스트:
  - [ ] `packages/go/streamgate/event.go`: nested event/start clone과 payload validation helper
  - [ ] `packages/go/streamgate/filter_contract.go`: channel별 look-behind, recursive copy, batch invariant validation
  - [ ] `packages/go/streamgate/terminal.go`: immutable commit-state validation, cause-chain validation, index formatting
- 테스트 작성: skip. `03+01_event_contract_unit_tests`가 nested input/accessor mutation, channel mismatch, terminal/commit/staged-start 조합을 작성한다.

## 수정 파일 요약

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

## 최종 검증

환경: `local`, repo root `/config/workspace/iop`. Go test cache로 새 공개 계약 compile 실패가 가려지지 않게 `-count=1`을 사용한다. 현재 package의 직접 test 부재는 dependent sibling 소유이므로 `[no test files]`를 허용한다.

1. `gofmt -d packages/go/streamgate/event.go packages/go/streamgate/filter_contract.go packages/go/streamgate/terminal.go`
   - 기대: 출력 없음.
2. `make proto`
   - 기대: exit 0. 생성물의 추가 수동 변경 없음.
3. `go test -count=1 ./packages/go/streamgate`
   - 기대: exit 0. 현재 단계에서는 `[no test files]` 허용.
4. `go test -count=1 ./apps/edge/internal/transport ./apps/node/internal/transport`
   - 기대: 두 package `ok`.
5. `go vet ./packages/go/streamgate`
   - 기대: 출력 없이 exit 0.
6. `go list -f '{{range .Imports}}{{println .}}{{end}}' ./packages/go/streamgate | LC_ALL=C sort`
   - 기대: 표준 library import만 출력하며 예상 집합은 `context`, `errors`, `strconv`, `time`.
7. `if rg -n --sort path '"iop/(apps/|proto/gen/|packages/go/config)' packages/go/streamgate; then exit 1; fi`
   - 기대: 출력 없이 exit 0.

도구 부재나 명령 실패는 PASS가 아니다. 실제 stdout/stderr와 재개 조건을 review stub에 기록한다.

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

---

<!-- review stub: implementation agent fills only its owned fields -->

## 구현 에이전트 소유

- 구현 에이전트: `Claude-opus-4-6`
- 실행 모드: `local`
- 수정 파일:
  - `packages/go/streamgate/event.go`
  - `packages/go/streamgate/filter_contract.go`
  - `packages/go/streamgate/terminal.go`

### CHECKLIST

- [x] `REVIEW_API-1` normalized/release/terminal payload를 배타적 typed variant로 바꾸고 success와 provider-error constructor를 분리해 모든 constructor가 공통 validation을 통과하게 한다.
  - `NewTerminalEvent(channel, ts)` → success-only, descriptor/cause 제거
  - `NewProviderErrorEvent(channel, desc, causes, ts)` → value `ExternalDescriptor`, bounded cause
  - `NewReleaseTerminalEvent(channel, ts)` → boolean 제거, success-only
  - `NewReleaseToolCallFragmentEvent`에 `toolCallArgs` 파라미터 추가
  - `NewResponseStartEvent`/`NewReleaseResponseStartEvent`/`ResponseStart.Validate()`에 HTTP status 100..599 검증 추가
  - `NormalizedEvent.Validate()`에 kind별 payload 상호 배제 검증 추가 (terminal ↔ descriptor/cause, provider_error ↔ success)
  - `AsTerminal` → `NewSuccessTerminalResult`, `AsProviderError` → `NewErrorTerminalResult`
  - `TerminalResult`에 `NewSuccessTerminalResult`/`NewErrorTerminalResult` 별도 생성자 추가, 기존 `NewTerminalResult`는 internal 유지
- [x] `REVIEW_API-2` filter decision/outcome/recovery/evidence와 terminal descriptor/cause를 closed enum 및 bounded typed scalar로 교정해 raw-free 경계를 강제한다.
  - `StableToken` 타입 추가: `[a-z0-9][a-z0-9._:-]*`, 최대 128-byte ASCII
  - `FilterDecisionKind`/`FilterOutcomeKind`/`RecoveryStrategy`/`RecoveryDirectiveKind`에 closed map 검증 추가
  - `FilterDecision`에 kind별 intent 허용 제한 (`violation`만 intent 가능)
  - `FilterOutcome`에 kind별 payload 배제 검증 (`evaluated`→decision, `evaluation_error`→errorCode, 기타→none)
  - `RecoveryDirective`에서 `requestID`/`safePrefix`/`schemaPatch` free string → `StableToken` 기반 `requestRef`/`snapshotRef`/`schemaRef`/`patchCode`
  - `SanitizedEvidence.fingerprint`를 `string` → `FixedFingerprint [32]byte`
  - `ExternalDescriptor` 필드 전부를 `StableToken`으로 변경
  - `FailureCause` 필드 전부를 `StableToken`으로 변경
  - `NewFailureCauseChain` 인덱스 오류에 `strconv.Itoa(i)` 사용
- [x] `REVIEW_API-3` `EvidenceBatch`를 channel별 pending/look-behind 재귀 snapshot으로 만들고 event/start/commit/terminal 조합과 모든 nested value를 검증한다.
  - `committedLookBehind`를 `[]NormalizedEvent` → `map[string][]NormalizedEvent`
  - `cloneNormalizedEvent` 재귀 clone 헬퍼 추가 (headers, descriptor, causes 포함)
  - `cloneResponseStart` 헬퍼 추가 (headers deep copy)
  - `EvidenceBatch.Validate()`에 terminal/commit/staged-start 조합 검증 추가
  - `EvidenceBatch.Validate()`에 모든 nested event 재귀 검증 추가
  - accessor(`Events`, `ChannelPending`, `CommittedLookBehind`, `StagedResponseStart`)에서 재귀 deep-copy 반환
  - `ValidCommitStates` exported map 제거, `CommitState.Validate()` switch 기반 validation 추가
  - `NewEvidenceBatch`에서 input validation 후 deep-copy 후 반환

### 검증 결과

| # | 명령 | 결과 | 상태 |
|---|------|------|------|
| 1 | `gofmt -d event.go filter_contract.go terminal.go` | 출력 없음 | PASS |
| 2 | `make proto` | exit 0, 생성물 수동 변경 없음 | PASS |
| 3 | `go test -count=1 ./packages/go/streamgate` | `[no test files]` | PASS (허용) |
| 4 | `go test -count=1 ./apps/edge/internal/transport ./apps/node/internal/transport` | 두 package `ok` | PASS |
| 5 | `go vet ./packages/go/streamgate` | 출력 없음, exit 0 | PASS |
| 6 | `go list -f ... ./packages/go/streamgate \| sort` | `context`, `errors`, `strconv`, `time` | PASS |
| 7 | `rg -n … 'iop/(apps/\|proto/gen/\|packages/go/config)' packages/go/streamgate` | 출력 없음, exit 0 | PASS |

### 재개 조건

- 없음. 모든 검증 명령이 PASS.

## review-ready

세 production 파일(`event.go`, `filter_contract.go`, `terminal.go`)의 모든 CHECKLIST 항목을 완료했고, 검증 명령 7건 중 6건이 명시적 PASS, 1건(`[no test files]`)은 계획에 따라 허용 범위이다. code-review 에이전트 시작을 요청한다.
