<!-- task=m-stream-evidence-gate-core/01_event_contract_types plan=3 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 회귀를 통과했지만 public API probe에서 terminal 평가 전이, closed enum, channel snapshot, release/commit 경계가 다시 열려 있음이 확인됐다. 승인된 SDD S03/S09/S21과 최신 FAIL의 Required finding만 같은 세 production 파일에서 닫는다.

## Archive Evidence Snapshot

- 입력 pair:
  - `agent-task/m-stream-evidence-gate-core/01_event_contract_types/plan_local_G04_2.log`
  - `agent-task/m-stream-evidence-gate-core/01_event_contract_types/code_review_local_G05_2.log`
- 역할 provenance:
  - Plan: `GPT-5`
  - Implementation: `Claude-opus-4-6`
  - Review: ledger는 `pending`이지만 verdict는 사용자 예외로 Plan과 같은 `GPT-5`가 리뷰했음을 기록한다.
  - 현재 후속 Plan도 사용자의 identity preflight 무시 지시에 따라 `GPT-5`로 진행한다. 다음 Implementation과 Review는 runtime이 현재 Plan 및 서로와 다른 identity로 배정한다.
- 판정: `FAIL`
- Required:
  - terminal event가 최종 filter evaluation 전에 `transport_uncommitted|stream_open` batch로 표현되지 못하고 이미 `terminal_committed`인 상태만 허용된다.
  - sanitized evidence의 unknown event/outcome과 pending/look-behind channel mismatch 및 terminal tail 혼입이 허용된다.
  - public `ReleaseEvent`가 response-start와 terminal을 허용해 `CommitResponseStart`/`CommitTerminal` 경계를 우회한다.
  - normalized event, filter outcome, recovery directive의 반대 payload와 nested value가 완전 검증되지 않고 generic `NewTerminalResult`가 공개돼 있다.
- 기존 검증 evidence: formatter, proto generation, package compile, Edge/Node transport regression, `go vet`, import boundary는 PASS했지만 public API probe는 위 네 계약 위반을 재현했다.
- 영향 파일: `packages/go/streamgate/event.go`, `packages/go/streamgate/filter_contract.go`, `packages/go/streamgate/terminal.go`
- Roadmap carryover: 이 pair는 `event-contract`의 production 타입 교정만 담당한다. 지속 회귀 테스트는 dependent `03+01_event_contract_unit_tests`, codec/host fixture closure는 `04+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`의 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 비노출 경계
- 필수 불변조건:
  - terminal/provider-error event가 정확히 하나인 final evaluation batch는 `transport_uncommitted|stream_open`에서만 존재하고, `terminal_committed`는 batch 입력이 아닌 종료 상태다.
  - staged response-start는 `transport_uncommitted` terminal evaluation에서도 보존되며, pending/look-behind는 key와 event channel이 일치하는 non-terminal event만 가진다.
  - `ReleaseEvent`는 text/reasoning/tool fragment만 표현한다. response-start와 terminal은 `ReleaseSink.CommitResponseStart`/`CommitTerminal`만 사용한다.
  - 모든 discriminated value는 required/forbidden payload를 한 `Validate` 경계에서 검사하고, 오류를 반환하는 public constructor는 완성된 값을 해당 `Validate`에 통과시킨다.
  - stable token, descriptor, cause chain, event/outcome enum은 nested validation에서도 closed set과 길이/문법을 다시 검증한다.
- 호출부:
  - 현재 product source/test에서 세 파일 밖의 관련 constructor call site는 없다.
  - 세 파일 내부 accessor/constructor와 `ReleaseSink` contract만 새 폐쇄 경계에 맞춘다.
- 테스트 작성: 이 pair에서는 repository test를 추가하지 않는다. persistent 정상/경계 regression은 dependent `03+01_event_contract_unit_tests`가 소유한다. 대신 현재 pair는 `/tmp`의 외부 public API probe로 최신 FAIL 재현 조건을 즉시 검증한다.
- 제외:
  - `*_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` terminal final-evaluation batch와 sanitized evidence enum/channel/tail 불변조건을 constructor와 `Validate` 양쪽에서 닫는다.
- [ ] `REVIEW_API-2` `ReleaseEvent`를 text/reasoning/tool safe candidate로 제한하고 response-start/terminal 공개 constructor와 accessor를 제거한다.
- [ ] `REVIEW_API-3` normalized/filter/recovery/terminal discriminated value의 required/forbidden/nested validation을 단일 원본으로 만들고 모든 오류 반환 public constructor가 이를 호출하게 한다.
- [ ] 계획에 명시한 local 검증 명령을 실행하고 실제 stdout/stderr를 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 채우고 review-ready를 보고한 뒤 현재 실행을 종료한다. code-review를 실행하거나 다음 에이전트를 시작하지 않는다.

### [REVIEW_API-1] Final evaluation batch와 closed snapshot

- 문제:
  - `packages/go/streamgate/filter_contract.go:746-783,877-905`는 terminal batch를 이미 downstream commit이 끝난 `terminal_committed`에만 허용하고 staged response-start를 금지해 SDD S03의 pre-commit final evaluation을 표현하지 못한다.
  - `packages/go/streamgate/filter_contract.go:572-645`는 `SanitizedEvidence`의 `EventKind`와 `FilterOutcomeKind`가 known set인지 검사하지 않는다.
  - `packages/go/streamgate/filter_contract.go:797-825,857-875`는 pending/look-behind event channel과 map key 일치 및 terminal event tail 혼입 금지를 검사하지 않는다.
- 해결 방법:
  - batch lifecycle 검증 helper 하나를 constructor와 `EvidenceBatch.Validate`에서 공유한다.
  - `terminalFlag=true`는 top-level events에 terminal/provider-error가 정확히 하나 있어야 하고 commit state는 `transport_uncommitted|stream_open`만 허용한다. `terminal_committed`는 terminal 여부와 무관하게 새 evaluation batch를 거부한다.
  - staged response-start는 `transport_uncommitted`에서 valid `ResponseStart`이면 terminal batch에도 보존하고, `stream_open`에서는 금지한다.
  - pending/look-behind는 non-empty key, nested event `Validate`, `ev.Channel()==key`, non-terminal kind를 모두 요구한다.
  - `EventKind.Validate`와 `FilterOutcomeKind.Validate`를 closed switch로 두고 `SanitizedEvidence` constructor/`Validate`가 둘 다 호출한다.
- Before (`packages/go/streamgate/filter_contract.go:746-762`):

```go
if terminalFlag {
	// ...
	if commitState != CommitStateTerminalCommitted {
		return EvidenceBatch{}, errors.New("streamgate: terminal batch requires terminal_committed state")
	}
	if stagedStart != nil {
		return EvidenceBatch{}, errors.New("streamgate: terminal batch must not have a staged response start")
	}
}
```

- After:

```go
func validateEvidenceBatchLifecycle(
	events []NormalizedEvent,
	stagedStart *ResponseStart,
	terminalFlag bool,
	commitState CommitState,
) error {
	// terminal evaluation is pre-commit: transport_uncommitted or stream_open.
	// terminal_committed is done and cannot form another EvidenceBatch.
}

func validateEvidenceTail(channel string, events []NormalizedEvent) error {
	// Require valid non-terminal events whose Channel equals channel.
}
```

- 수정 파일 및 체크리스트:
  - [ ] `packages/go/streamgate/event.go`: closed `EventKind` validation helper
  - [ ] `packages/go/streamgate/filter_contract.go`: closed evidence enum, shared batch lifecycle, channel/tail validation
- 테스트 작성: repository test는 skip한다. `03+01_event_contract_unit_tests`가 unknown enum, channel mismatch, terminal tail, staged pre-commit terminal, stream-open terminal, terminal-committed rejection을 영구 회귀 테스트로 작성한다. 이 pair에서는 동일 public cases를 `/tmp` probe로 검증한다.

### [REVIEW_API-2] Release와 commit 경계 분리

- 문제:
  - `packages/go/streamgate/event.go:502-550,611-650`은 `ReleaseEvent`에 response-start/terminal payload를 저장하고 `NewReleaseResponseStartEvent`/`NewReleaseTerminalEvent`를 공개한다.
  - `packages/go/streamgate/event.go:662-720`의 accessor도 response-start/terminal을 release variant로 노출해 `ReleaseSink.CommitResponseStart`/`CommitTerminal`을 우회할 수 있다.
- 해결 방법:
  - `ReleaseEvent` storage와 public API에서 response-start status/header 및 terminal flag를 제거한다.
  - `NewReleaseResponseStartEvent`, `NewReleaseTerminalEvent`, `AsResponseStart`, `Headers`, `AsTerminal`을 제거한다. 현재 repo 외 call site가 없으므로 compatibility wrapper를 남기지 않는다.
  - `ReleaseEvent.Validate`는 `text_delta|reasoning_delta|tool_call_fragment` closed set만 허용하고 kind별 required/forbidden payload를 검사한다.
  - 세 safe constructor는 값을 조립한 뒤 `Validate`를 호출한다. response-start/terminal은 기존 `ReleaseSink` 전용 메서드만 남긴다.
- Before (`packages/go/streamgate/event.go:611-650`):

```go
func NewReleaseTerminalEvent(channel string, ts time.Time) (ReleaseEvent, error) {
	return ReleaseEvent{
		kind:            EventKindTerminal,
		channel:         channel,
		timestamp:       ts,
		terminalSuccess: true,
	}, nil
}
```

- After:

```go
func (r ReleaseEvent) Validate() error {
	switch r.kind {
	case EventKindTextDelta, EventKindReasoningDelta, EventKindToolCallFragment:
		// Validate the one allowed payload and reject all opposite payloads.
	default:
		return errors.New("streamgate: release event kind is not releasable")
	}
}
```

- 수정 파일 및 체크리스트:
  - [ ] `packages/go/streamgate/event.go`: safe-only release storage, constructors, validation, accessors
  - [ ] `packages/go/streamgate/terminal.go`: `ReleaseSink` 주석과 메서드 책임을 safe release/start/terminal 분리와 일치시킴
- 테스트 작성: repository test는 skip한다. dependent test sibling이 exported API surface와 safe variant를 검증한다. 이 pair에서는 forbidden constructor symbol search와 public probe를 수행한다.

### [REVIEW_API-3] Discriminated value validation 단일 원본

- 문제:
  - `packages/go/streamgate/event.go:359-400`은 response/text/reasoning/tool kind의 반대 payload를 금지하지 않고 provider-error descriptor/cause를 nested validation하지 않는다.
  - `packages/go/streamgate/filter_contract.go:238-263,387-417`은 outcome/directive의 반대 payload를 금지하지 않고 nested stable token을 문법까지 재검증하지 않는다.
  - `packages/go/streamgate/terminal.go:143-152,287-295,373-443`은 cause/descriptor nested token을 non-empty로만 검사하며 boolean generic `NewTerminalResult`를 공개한다.
  - 여러 public constructor가 완성된 value의 `Validate`를 호출하지 않고 일부 검사를 중복한다.
- 해결 방법:
  - `NormalizedEvent.Validate`는 kind별 required payload와 모든 forbidden payload를 동시에 검사한다. provider-error는 descriptor와 전체 cause chain을 재검증한다.
  - `FilterOutcome.Validate`는 evaluated/error/empty variants의 decision/errorCode 상호 배제를, `RecoveryDirective.Validate`는 exact/continuation/schema의 모든 반대 field를 검사한다.
  - required/optional `StableToken`, `FailureCause`, `FailureCauseChain`, `ExternalDescriptor`, evidence/decision/intent의 `Validate`는 같은 128-byte ASCII grammar와 closed enum을 재검증한다.
  - 오류를 반환하는 public constructor는 입력을 typed value로 조립한 뒤 자신의 `Validate`를 호출한다. fixed outcome constructor도 validation failure를 전달할 수 있는 반환 계약으로 통일한다.
  - `NewTerminalResult`를 package-private `newTerminalResult`로 내리고 공개 API에는 `NewSuccessTerminalResult`/`NewErrorTerminalResult`만 둔다.
- Before (`packages/go/streamgate/terminal.go:373-381`):

```go
func NewTerminalResult(
	channel string,
	success, errResult bool,
	desc *ExternalDescriptor,
	causes FailureCauseChain,
	occurredAt time.Time,
) (TerminalResult, error) {
```

- After:

```go
func newTerminalResult(
	channel string,
	success, errResult bool,
	desc *ExternalDescriptor,
	causes FailureCauseChain,
	occurredAt time.Time,
) (TerminalResult, error) {
	// Assemble once, call TerminalResult.Validate, and return only valid values.
}
```

- 수정 파일 및 체크리스트:
  - [ ] `packages/go/streamgate/event.go`: exact kind-payload validation과 constructor-to-`Validate`
  - [ ] `packages/go/streamgate/filter_contract.go`: exact outcome/directive payload validation과 nested token/enum validation
  - [ ] `packages/go/streamgate/terminal.go`: stable token/cause/descriptor/terminal nested validation, private generic constructor
- 테스트 작성: repository test는 skip한다. dependent test sibling이 same-package malformed private payload와 constructor boundary를 검증한다. 이 pair에서는 compile/vet/public surface probe로 공개 경계를 검증한다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `packages/go/streamgate/event.go` | `REVIEW_API-1`, `REVIEW_API-2`, `REVIEW_API-3` |
| `packages/go/streamgate/filter_contract.go` | `REVIEW_API-1`, `REVIEW_API-3` |
| `packages/go/streamgate/terminal.go` | `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; else echo "PASS: no forbidden imports"; fi`
   - 기대: `PASS: no forbidden imports`.
8. `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`.
9. 모델의 `apply_patch` 도구로 아래 patch를 적용해 고정 경로 `/tmp/iop_streamgate_public_probe.go`를 만든다.

```diff
*** Begin Patch
*** Add File: /tmp/iop_streamgate_public_probe.go
+package main
+
+import (
+	"fmt"
+	"time"
+
+	"iop/packages/go/streamgate"
+)
+
+func requireError(name string, err error) {
+	if err == nil {
+		panic(name + ": expected error")
+	}
+}
+
+func requireOK(name string, err error) {
+	if err != nil {
+		panic(name + ": " + err.Error())
+	}
+}
+
+func main() {
+	now := time.Unix(1, 0).UTC()
+	var fp streamgate.FixedFingerprint
+	fp[0] = 1
+
+	_, err := streamgate.NewSanitizedEvidence(
+		streamgate.EventKind("unknown_event"), "text", "rule", "code", fp, 0, 0,
+		streamgate.FilterOutcomeKindEvaluated, now,
+	)
+	requireError("unknown event kind", err)
+	_, err = streamgate.NewSanitizedEvidence(
+		streamgate.EventKindTextDelta, "text", "rule", "code", fp, 0, 0,
+		streamgate.FilterOutcomeKind("unknown_outcome"), now,
+	)
+	requireError("unknown outcome kind", err)
+
+	text, err := streamgate.NewTextDeltaEvent("text", "safe", now)
+	requireOK("text event", err)
+	_, err = streamgate.NewEvidenceBatch(
+		nil,
+		map[string][]streamgate.NormalizedEvent{"reasoning": {text}},
+		nil, nil, false, streamgate.CommitStateTransportUncommitted, now,
+	)
+	requireError("pending channel mismatch", err)
+
+	terminal, err := streamgate.NewTerminalEvent("text", now)
+	requireOK("terminal event", err)
+	start, err := streamgate.NewResponseStart("text", 200, map[string]string{"x-test": "ok"}, now)
+	requireOK("response start", err)
+	_, err = streamgate.NewEvidenceBatch(
+		[]streamgate.NormalizedEvent{terminal}, nil, nil, &start, true,
+		streamgate.CommitStateTransportUncommitted, now,
+	)
+	requireOK("uncommitted terminal evaluation", err)
+	_, err = streamgate.NewEvidenceBatch(
+		[]streamgate.NormalizedEvent{terminal}, nil, nil, nil, true,
+		streamgate.CommitStateStreamOpen, now,
+	)
+	requireOK("stream-open terminal evaluation", err)
+	_, err = streamgate.NewEvidenceBatch(
+		[]streamgate.NormalizedEvent{terminal}, nil, nil, nil, true,
+		streamgate.CommitStateTerminalCommitted, now,
+	)
+	requireError("terminal-committed evaluation", err)
+	_, err = streamgate.NewEvidenceBatch(
+		nil,
+		map[string][]streamgate.NormalizedEvent{"text": {terminal}},
+		nil, nil, false, streamgate.CommitStateTransportUncommitted, now,
+	)
+	requireError("terminal event in pending tail", err)
+
+	fmt.Println("PASS: streamgate public contract probe")
+}
*** End Patch
```

   - `go run /tmp/iop_streamgate_public_probe.go`
   - 기대: stdout에 `PASS: streamgate public contract probe`, stderr 없음, exit 0.
   - 실행 직후 실제 stdout/stderr를 review stub에 기록한다.
   - 기록 후 모델의 `apply_patch` 도구로 아래 patch를 적용해 임시 파일을 삭제한다.

```diff
*** Begin Patch
*** Delete File: /tmp/iop_streamgate_public_probe.go
*** End Patch
```

   - 기대: `/tmp/iop_streamgate_public_probe.go`가 남지 않는다.
10. `git diff --check -- packages/go/streamgate/event.go packages/go/streamgate/filter_contract.go packages/go/streamgate/terminal.go`
    - 기대: 출력 없이 exit 0.

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

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