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

# Stream Gate 값 계약 재귀 검증 후속 구현 계획

## 구현 에이전트 규칙

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

## 배경

직전 구현은 형식·compile·transport 검증을 통과했지만 public constructor와 private nested value의 `Validate` 경계가 서로 달라 malformed 값 18개가 수용됐다. 승인된 SDD S03/S09/S21과 최신 FAIL의 네 Required만 같은 세 contract 파일에서 닫는다.

## Archive Evidence Snapshot

- 입력 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개를 재현했다.
- 영향 파일:
  - `packages/go/streamgate/event.go`
  - `packages/go/streamgate/filter_contract.go`
  - `packages/go/streamgate/terminal.go`
- 역할 identity preflight 예외:
  - 입력 review ledger는 두 후속 역할이 미할당된 채 verdict executor와 Plan 역할 identity가 동일한 상태였고 사용자 예외로 진행됐다.
  - 사용자가 미할당/동일 역할 identity 조건을 무시하고 진행하라고 명시했다. 기존 runtime ledger는 수정하지 않으며, 이 pair도 top metadata를 runtime 기본 상태로 유지한 채 같은 예외를 handoff 근거로 기록한다.
- Roadmap carryover: 이 task는 `event-contract` production 타입의 부분 구현이다. persistent unit test write set은 dependent `03+01_event_contract_unit_tests`, codec/host fixture 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`
- 적용 기준:
  - `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 조합을 검사한다.
- 테스트 작성:
  - repository `*_test.go`는 수정하지 않는다. 해당 write set은 dependent `03+01_event_contract_unit_tests`가 소유한다.
  - 이 pair의 PASS에는 `/tmp/iop_streamgate_contract_overlay`에 현재 세 source를 복사하고 public constructor 및 private malformed shape를 함께 검증하는 결정적 same-package regression test를 포함한다.
- 제외:
  - `packages/go/streamgate/*_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` text/reasoning/tool normalized constructor를 assemble-then-`Validate` 경계로 통일한다.
- [ ] `REVIEW_API-2` filter outcome/directive의 반대 payload와 filter/intent/evidence의 nested token·closed enum을 재검증한다.
- [ ] `REVIEW_API-3` failure cause/descriptor/terminal result가 required token과 bounded cause chain 전체를 재귀 검증하게 한다.
- [ ] `REVIEW_API-4` staged response-start validation을 terminal 여부와 무관한 lifecycle 단일 경계로 옮긴다.
- [ ] 계획에 명시한 local 검증 명령과 temporary same-package regression을 실행하고 실제 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`는 channel/timestamp와 일부 tool 필드만 검사한 뒤 값을 바로 반환한다. 반면 `NormalizedEvent.Validate`는 text/reasoning/tool arguments의 non-empty 조건을 필수로 선언해 public constructor와 validation 원본이 갈라져 있다.
- 해결 방법:
  - 세 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`로 통일
- 테스트 작성: repository test는 skip한다. temporary same-package regression의 `TestNormalizedConstructorsRejectEmptyPayload`가 빈 text, 빈 reasoning, 빈 tool arguments를 모두 거부하고 non-empty 경계를 수용하는지 검증한다.

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

- 문제:
  - `packages/go/streamgate/filter_contract.go:249-273`의 `FilterOutcome.Validate`는 evaluated의 `errorCode`, evaluation-error의 `decision`, static outcome의 양 payload를 금지하지 않고 required `errorCode` grammar도 재검증하지 않는다.
  - `packages/go/streamgate/filter_contract.go:398-427`의 `RecoveryDirective.Validate`는 exact/continuation/schema 반대 필드를 금지하지 않고 required token을 non-empty로만 확인한다.
  - `packages/go/streamgate/filter_contract.go:101-128,508-527,636-661`의 `FilterDecision`, `RecoveryIntent`, `SanitizedEvidence`는 nested required token을 grammar까지 재검증하지 않으며 `SanitizedEvidence`는 event/outcome closed enum도 다시 검사하지 않는다.
- 해결 방법:
  - required token용 재검증 helper를 constructor의 `validateStableToken`과 같은 원본으로 두고 optional helper와 구분한다.
  - `FilterDecision`의 consumer/filter/rule, `FilterOutcome`의 error code, `RecoveryDirective`의 kind별 token, `RecoveryIntent`의 reason, `SanitizedEvidence`의 filter rule/descriptor code를 required helper에 통과시킨다.
  - `FilterOutcome.Validate`와 `RecoveryDirective.Validate`는 각 kind의 required payload 외 모든 반대 payload가 zero인지 검사한다.
  - `SanitizedEvidence.Validate`는 `EventKind.Validate`, `FilterOutcomeKind.Validate`를 호출한다.
  - 오류를 반환하는 filter value 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 decision/outcome, recovery directive/intent, sanitized evidence의 exact payload·token·enum validation
- 테스트 작성: repository test는 skip한다. temporary regression의 `TestFilterValuesRejectOppositePayloadsAndMalformedNestedTokens`가 outcome/directive 반대 payload, filter/intent/evidence invalid token, unknown event/outcome을 직접 조립해 모두 거부되는지 검증한다.

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

- 문제:
  - `packages/go/streamgate/terminal.go:157-173`의 `FailureCause.Validate`는 required stage/code를 non-empty로만 확인한다.
  - `packages/go/streamgate/terminal.go:311-324`의 `ExternalDescriptor.Validate`는 required error type/message를 non-empty로만 확인한다.
  - `packages/go/streamgate/terminal.go:450-472`의 `TerminalResult.Validate`는 error descriptor와 failure cause chain의 최대 길이·각 cause를 검사하지 않는다.
- 해결 방법:
  - `FailureCause.Validate`에서 stage/code는 required helper, consumer/filter/rule은 optional helper로 재검증한다.
  - `ExternalDescriptor.Validate`에서 errorType/message는 required helper, code/param은 optional helper로 재검증한다.
  - `TerminalResult.Validate`의 error branch는 descriptor를 재귀 검증하고 `failureCauses.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의 required token·descriptor·cause chain recursive validation
- 테스트 작성: repository test는 skip한다. temporary regression의 `TestTerminalValuesRejectMalformedNestedValues`가 invalid required stage/code/type/message, malformed descriptor/cause, 5-entry private chain을 모두 거부하는지 검증한다.

### [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 `100..599`와 header 계약을 우회할 수 있다.
- 해결 방법:
  - `validateEvidenceBatchLifecycle` 초입에서 `stagedStart != nil`이면 terminal 여부와 무관하게 `stagedStart.Validate()`를 호출한다.
  - 그 뒤 `transport_uncommitted`에서만 staged start를 허용하고, `stream_open`/`terminal_committed` 조합을 기존 lifecycle 규칙대로 거부한다.
  - 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 nested validation과 commit-state 검사를 shared lifecycle helper에 단일화
- 테스트 작성: repository test는 skip한다. temporary regression의 `TestEvidenceBatchRejectsInvalidNestedResponseStart`가 status 99인 private staged start를 `NewEvidenceBatch`와 `EvidenceBatch.Validate` 양쪽에서 거부하는지 검증한다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `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` |

## 최종 검증

환경: `local`, repo root `/config/workspace/iop`. Go test cache가 constructor/validation 회귀를 가리지 않도록 `-count=1`을 사용한다. repository persistent tests는 dependent sibling 소유이므로 현재 package의 `[no test files]`는 허용하되, 아래 temporary same-package regression PASS는 필수다.

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. dependent test sibling 실행 전에는 `[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`
   - 기대: `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. `test ! -e /tmp/iop_streamgate_contract_overlay && mkdir -p /tmp/iop_streamgate_contract_overlay && cp packages/go/streamgate/*.go /tmp/iop_streamgate_contract_overlay/`
   - 기대: exit 0. 기존 임시 경로가 있으면 덮어쓰지 말고 차단 evidence를 기록한다.
10. 모델의 `apply_patch` 도구로 `/tmp/iop_streamgate_contract_overlay/go.mod`와 `/tmp/iop_streamgate_contract_overlay/contract_overlay_test.go`를 만든다.

```go
// go.mod
module iop-streamgate-contract-overlay

go 1.24
```

```go
package streamgate

import (
	"testing"
	"time"
)

func requireOverlayError(t *testing.T, name string, err error) {
	t.Helper()
	if err == nil {
		t.Fatalf("%s: expected error", name)
	}
}

func overlayToken(v string) StableToken {
	return StableToken{value: v}
}

func overlayEvidence(now time.Time) SanitizedEvidence {
	var fp FixedFingerprint
	fp[0] = 1
	return SanitizedEvidence{
		eventKind:      EventKindTextDelta,
		channel:        "text",
		filterRule:     overlayToken("rule"),
		outcome:        FilterOutcomeKindEvaluated,
		descriptorCode: overlayToken("code"),
		fingerprint:    fp,
		timestamp:      now,
	}
}

func overlayDecision(now time.Time) FilterDecision {
	return FilterDecision{
		kind:       FilterDecisionKindPass,
		consumerID: overlayToken("consumer"),
		filterID:   overlayToken("filter"),
		ruleID:     overlayToken("rule"),
		evidence:   overlayEvidence(now),
	}
}

func TestNormalizedConstructorsRejectEmptyPayload(t *testing.T) {
	now := time.Unix(1, 0).UTC()
	_, err := NewTextDeltaEvent("text", "", now)
	requireOverlayError(t, "empty text", err)
	_, err = NewReasoningDeltaEvent("reasoning", "", now)
	requireOverlayError(t, "empty reasoning", err)
	_, err = NewToolCallFragmentEvent("tool", "id", "name", "", now)
	requireOverlayError(t, "empty tool arguments", err)

	if _, err = NewTextDeltaEvent("text", "ok", now); err != nil {
		t.Fatalf("valid text: %v", err)
	}
	if _, err = NewReasoningDeltaEvent("reasoning", "ok", now); err != nil {
		t.Fatalf("valid reasoning: %v", err)
	}
	if _, err = NewToolCallFragmentEvent("tool", "id", "name", "{}", now); err != nil {
		t.Fatalf("valid tool fragment: %v", err)
	}
}

func TestFilterValuesRejectOppositePayloadsAndMalformedNestedTokens(t *testing.T) {
	now := time.Unix(1, 0).UTC()
	decision := overlayDecision(now)
	cases := map[string]error{
		"evaluated error code": (FilterOutcome{
			kind: FilterOutcomeKindEvaluated, decision: &decision, errorCode: overlayToken("error"),
		}).Validate(),
		"evaluation error decision": (FilterOutcome{
			kind: FilterOutcomeKindEvaluationError, decision: &decision, errorCode: overlayToken("error"),
		}).Validate(),
		"static outcome payload": (FilterOutcome{
			kind: FilterOutcomeKindDeferredByRequirement, decision: &decision,
		}).Validate(),
		"exact opposite payload": (RecoveryDirective{
			kind: RecoveryDirectiveKindExact, requestRef: overlayToken("request"), snapshotRef: overlayToken("snapshot"),
		}).Validate(),
		"continuation opposite payload": (RecoveryDirective{
			kind: RecoveryDirectiveKindContinuation, snapshotRef: overlayToken("snapshot"), requestRef: overlayToken("request"),
		}).Validate(),
		"schema opposite payload": (RecoveryDirective{
			kind: RecoveryDirectiveKindSchema, schemaRef: overlayToken("schema"), patchCode: overlayToken("patch"), requestRef: overlayToken("request"),
		}).Validate(),
		"decision token": (FilterDecision{
			kind: FilterDecisionKindPass, consumerID: overlayToken("Bad"), filterID: overlayToken("filter"),
			ruleID: overlayToken("rule"), evidence: overlayEvidence(now),
		}).Validate(),
		"directive token": (RecoveryDirective{
			kind: RecoveryDirectiveKindExact, requestRef: overlayToken("Bad"),
		}).Validate(),
		"intent token": (RecoveryIntent{
			strategy: RecoveryStrategyExactReplay,
			directive: RecoveryDirective{kind: RecoveryDirectiveKindExact, requestRef: overlayToken("request")},
			reason: overlayToken("Bad"),
		}).Validate(),
	}

	unknownEvent := overlayEvidence(now)
	unknownEvent.eventKind = EventKind("unknown")
	cases["unknown evidence event"] = unknownEvent.Validate()
	unknownOutcome := overlayEvidence(now)
	unknownOutcome.outcome = FilterOutcomeKind("unknown")
	cases["unknown evidence outcome"] = unknownOutcome.Validate()
	badRule := overlayEvidence(now)
	badRule.filterRule = overlayToken("Bad")
	cases["evidence rule token"] = badRule.Validate()
	badCode := overlayEvidence(now)
	badCode.descriptorCode = overlayToken("Bad")
	cases["evidence descriptor token"] = badCode.Validate()

	for name, err := range cases {
		requireOverlayError(t, name, err)
	}
}

func TestTerminalValuesRejectMalformedNestedValues(t *testing.T) {
	now := time.Unix(1, 0).UTC()
	validCause := FailureCause{stage: overlayToken("stage"), code: overlayToken("code")}
	validDesc := ExternalDescriptor{errorType: overlayToken("type"), message: overlayToken("message")}

	badStage := validCause
	badStage.stage = overlayToken("Bad")
	badCode := validCause
	badCode.code = overlayToken("Bad")
	badType := validDesc
	badType.errorType = overlayToken("Bad")
	badMessage := validDesc
	badMessage.message = overlayToken("Bad")
	badNestedCause := FailureCause{stage: overlayToken("Bad"), code: overlayToken("code")}

	cases := map[string]error{
		"required stage token": badStage.Validate(),
		"required cause code token": badCode.Validate(),
		"required descriptor type token": badType.Validate(),
		"required descriptor message token": badMessage.Validate(),
		"terminal descriptor": (TerminalResult{
			channel: "text", err: true, externalDesc: &badType, occurredAt: now,
		}).Validate(),
		"terminal nested cause": (TerminalResult{
			channel: "text", err: true, externalDesc: &validDesc,
			failureCauses: FailureCauseChain{causes: []FailureCause{badNestedCause}}, occurredAt: now,
		}).Validate(),
		"terminal cause bound": (TerminalResult{
			channel: "text", err: true, externalDesc: &validDesc,
			failureCauses: FailureCauseChain{causes: []FailureCause{
				validCause, validCause, validCause, validCause, validCause,
			}}, occurredAt: now,
		}).Validate(),
	}
	for name, err := range cases {
		requireOverlayError(t, name, err)
	}
}

func TestEvidenceBatchRejectsInvalidNestedResponseStart(t *testing.T) {
	now := time.Unix(1, 0).UTC()
	badStart := ResponseStart{channel: "text", status: 99, timestamp: now}
	_, err := NewEvidenceBatch(
		nil, nil, nil, &badStart, false, CommitStateTransportUncommitted, now,
	)
	requireOverlayError(t, "constructor invalid staged start", err)
	err = (EvidenceBatch{
		stagedStart: &badStart, commitState: CommitStateTransportUncommitted, capturedAt: now,
	}).Validate()
	requireOverlayError(t, "validate invalid staged start", err)
}
```

11. `(cd /tmp/iop_streamgate_contract_overlay && go test -count=1 .)`
    - 기대: `ok iop-streamgate-contract-overlay`.
12. 모델의 `apply_patch` 도구로 `/tmp/iop_streamgate_contract_overlay/contract_overlay_test.go`, `go.mod`, `event.go`, `filter_contract.go`, `terminal.go`를 삭제한 뒤 `rmdir /tmp/iop_streamgate_contract_overlay`를 실행한다.
    - 기대: `test ! -e /tmp/iop_streamgate_contract_overlay`가 exit 0.
13. `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를 실행하거나 다음 에이전트를 시작하지 않는다.
