<!-- task=m-model-group-mixed-provider-dispatch/04_custom_field_preservation plan=0 tag=SURFACE_FIELD -->

# Plan - SURFACE_FIELD

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

`CODE_REVIEW-local-G06.md`의 구현 에이전트 소유 섹션을 채우는 것까지가 구현이다. 코드를 바꾸고 검증을 실행한 뒤 실제 변경 내용과 stdout/stderr를 기록하고 active 파일은 그대로 둔 채 리뷰 준비를 보고한다. 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 구현을 막을 때만 review stub의 `사용자 리뷰 요청`을 채우고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `USER_REVIEW.md`, `complete.log`, archive log를 만들지 않는다. 환경/secret/service 차단, 범위 조정, 검증 공백은 사용자 리뷰가 아니라 계획 대비 변경 사항 또는 follow-up 근거로 기록한다.

## 배경

현재 Chat Completions provider-pool 요청은 raw body를 읽지만, route를 알기 전에 `decodeChatCompletionRequest`가 unknown field를 거부한다. Responses provider-pool은 envelope를 먼저 읽어 raw passthrough에서 unknown/Codex/provider-specific field를 보존하므로 Chat과 계약이 어긋난다. 이 작업은 model group Chat route도 Responses처럼 routing envelope와 strict normalized decode를 분리한다.

## 사용자 리뷰 요청 흐름

사용자 리뷰 요청은 선택된 Milestone lock 결정이 구현을 실제로 막을 때만 active `CODE_REVIEW-local-G06.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 직접 사용자 프롬프트, 채팅 선택지, `request_user_input` 호출은 금지하며, code-review가 요청 유효성 검증과 `USER_REVIEW.md` 작성을 소유한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md`
- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md)
- Task ids:
  - `custom-field-preservation`: Provider-pool Chat route raw body/envelope 분리와 unknown/Codex/provider-specific field 보존
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md`
- `agent-roadmap/sdd/routing-policy-model-orchestration/model-group-mixed-provider-dispatch/SDD.md`
- `agent-contract/outer/openai-compatible-api.md`
- `agent-spec/input/openai-compatible-surface.md`
- `apps/edge/internal/openai/chat_handler.go`
- `apps/edge/internal/openai/responses_handler.go`
- `apps/edge/internal/openai/types.go`
- `apps/edge/internal/openai/stream.go`
- `apps/edge/internal/openai/server_test.go`

### SDD 기준

- SDD: `agent-roadmap/sdd/routing-policy-model-orchestration/model-group-mixed-provider-dispatch/SDD.md`, 상태 `[승인됨]`, 잠금 해제.
- 대상 Acceptance Scenario: S07, S08.
- Evidence Map: S07은 Chat raw body preservation tunnel test, S08은 normalized provider 선택 시 supported field mapping/unsupported policy assertion을 요구한다.
- 구현 체크리스트는 Chat ingress decode 순서, provider tunnel body 보존, normalized path field 정책 테스트를 이 Evidence Map에서 역산했다.

### 테스트 환경 규칙

- test_env: `local`.
- `agent-test/local/rules.md`와 `agent-test/local/edge-smoke.md`를 읽었고 Edge/OpenAI-compatible 변경이므로 변경 패키지 대상 `go test`가 필수다.
- 현재 checkout 내부 검증만 사용한다. 외부 provider endpoint, credential, Docker, 장기 runtime 프리플라이트는 필요 없다.
- 검증 후보 명령은 실제 실행 확인됨: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -count=1`.

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

- 현재 `TestChatCompletionsMixedProviderPoolTunnelSelection`은 tunnel dispatch만 검증하고 Chat unknown field 보존은 검증하지 않는다.
- 현재 `TestResponsesProviderTunnelStreaming` 계열은 Responses raw passthrough 보존을 검증하지만 Chat provider-pool ingress의 strict decode 회귀를 막지 못한다.
- 이 계획은 provider-pool tunnel selected path의 unknown field 보존과 normalized selected path의 supported-field mapping/unknown drop-or-observe 정책을 새 regression test로 채운다.

### 심볼 참조

- renamed/removed symbol 없음.
- 새 helper 후보 `decodeChatCompletionEnvelope` 또는 `decodeChatCompletionRequest(..., strictUnknown bool)`는 신규 심볼이다.

### 분할 판단

- split decision policy를 먼저 평가했다.
- 공유 task group: `m-model-group-mixed-provider-dispatch`.
- sibling plan:
  - `04_custom_field_preservation`: 독립, predecessor 없음.
  - `05_sideband_observation`: 독립, predecessor 없음.
- 분리 사유: custom-field 작업은 Chat ingress/raw body와 handler tests 중심이고, sideband-observation은 service dispatch DTO/log/metric observation 중심이라 ownership과 검증 위험이 다르다.

### 범위 결정 근거

- Responses handler는 이미 envelope-first 구조이므로 수정하지 않는다.
- service provider selection, RunDispatch observation, metric label 변경은 `05_sideband_observation` 범위다.
- contract/spec 전체 동기화는 Milestone의 `contract-sync` task가 따로 있으므로 여기서는 구현에 직접 필요한 테스트와 handler 동작만 다룬다.

### 빌드 등급

- `local-G06`: OpenAI handler 내부의 bounded behavior change이며 로컬 Go test로 검증 가능하지만, provider-pool tunnel/normalized 분기와 raw JSON 보존을 함께 다뤄 중간 난도다.

## 구현 체크리스트

- [x] [SURFACE_FIELD-1] Chat Completions provider-pool route가 strict unknown-field decode 전에 routing envelope를 읽고, model group selector 거부와 route resolution을 envelope 기준으로 수행하게 한다.
- [x] [SURFACE_FIELD-2] OpenAI-compatible provider 선택 시 `rewriteChatCompletionModel`이 model rewrite와 catalog generation policy 외에는 caller raw body unknown/Codex/provider-specific field를 보존함을 regression test로 검증한다. 검증: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'ProviderPool.*Unknown|ProviderPoolRejectsResponseMode|MixedProviderPool' -count=1`.
- [x] [SURFACE_FIELD-3] normalized-provider 선택 시 standard supported field만 normalized `RunRequest.Input`에 반영되고 unknown provider-specific field가 native request에 임의 전파되지 않음을 regression test로 검증한다. 검증: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'ProviderPool.*Unknown|ProviderPool.*Normalized' -count=1`.
- [x] [SURFACE_FIELD-4] 최종 OpenAI handler 패키지 검증을 실행한다. 검증: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -count=1`.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [SURFACE_FIELD-1] Envelope-First Chat Provider-Pool Decode

#### 문제

- [chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:37): handler가 route dispatch 전 `decodeChatCompletionRequest`를 호출한다.
- [chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:337): `decodeChatCompletionRequest`가 allowlist 밖 field를 즉시 `400`으로 거부한다.
- [responses_handler.go](/config/workspace/iop/apps/edge/internal/openai/responses_handler.go:35): Responses는 이미 envelope-first로 provider passthrough unknown field를 보존한다.

#### 해결 방법

Before ([chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:37)):

```go
var req chatCompletionRequest
if err := decodeChatCompletionRequest(json.NewDecoder(bytes.NewReader(rawBody)), &req); err != nil {
    writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
    return
}
```

After:

```go
env, err := decodeChatCompletionEnvelope(rawBody)
if err != nil { ... }
dispatch, ok := s.resolveRouteDispatch(env.Model)
...
var req chatCompletionRequest
if dispatch.ProviderPool {
    err = decodeChatCompletionRequestLenient(json.NewDecoder(bytes.NewReader(rawBody)), &req)
} else {
    err = decodeChatCompletionRequest(json.NewDecoder(bytes.NewReader(rawBody)), &req)
}
```

`decodeChatCompletionRequestLenient`는 JSON 형식과 known-field validation은 유지하지만 unknown top-level field를 거부하지 않는다. `metadata.iop_response_mode` selector 거부는 envelope metadata + principal metadata merge 뒤에 계속 수행한다.

#### 수정 파일 및 체크리스트

- [x] `apps/edge/internal/openai/chat_handler.go`: envelope type/helper 추가 (decode 함수 strict flag 추가 대신 `chatCompletionEnvelope` 타입과 `decodeChatCompletionEnvelope` 함수 신규 생성).
- [x] `apps/edge/internal/openai/chat_handler.go`: provider-pool route에서 lenient decode 사용, non-provider/legacy strict decode 유지.
- [x] `apps/edge/internal/openai/chat_handler.go`: selector rejection 위치가 provider-pool dispatch 전임을 유지.

#### 테스트 작성

- `apps/edge/internal/openai/server_test.go`에 provider-pool unknown field 보존/normalized 정책 테스트 추가.

#### 중간 검증

```bash
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'ProviderPool.*Unknown|ProviderPoolRejectsResponseMode' -count=1
```

### [SURFACE_FIELD-2] Provider Tunnel Raw Body Preservation Test

#### 문제

- [chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:258): provider-pool tunnel body는 `rewriteChatCompletionModel(rawBody, target, req)`로 생성된다.
- [chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:534): rewrite helper는 raw JSON map에서 model/max token/thinking policy만 수정하므로 unknown field 보존이 가능하지만 test가 없다.

#### 해결 방법

`TestChatCompletionsProviderPoolTunnelPreservesUnknownFields`를 추가한다. request body에 `store`, `parallel_tool_calls`, `codex_trace`, `custom_provider_options`, nested unknown object를 넣고 selected tunnel path로 dispatch한다. `fake.tunnelBodies[0]`를 JSON map으로 읽어 `model == served-model`, unknown fields preserved, no IOP sideband/header가 pure response에 섞이지 않음을 검증한다.

#### 수정 파일 및 체크리스트

- [x] `apps/edge/internal/openai/server_test.go`: tunnel path fixture 추가 (`tunnelFrames`, `tunnelServedTarget`).
- [x] `apps/edge/internal/openai/server_test.go`: `fake.tunnelBodiesSnapshot()` 활용 (기존 snapshot pattern 사용).

#### 테스트 작성

- 작성한다. Assertion 목표: model rewrite 외 unknown/Codex/provider-specific field 보존.

#### 중간 검증

```bash
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'ProviderPoolTunnelPreservesUnknownFields' -count=1
```

### [SURFACE_FIELD-3] Normalized Provider Field Policy Test

#### 문제

- [types.go](/config/workspace/iop/apps/edge/internal/openai/types.go:70): normalized input은 known standard fields만 `RunRequest.Input`으로 변환한다.
- SDD S08은 normalized provider 선택 시 변환 가능한 standard field만 native adapter request로 가고, provider-specific unknown field는 임의 invent되지 않아야 한다.

#### 해결 방법

`TestChatCompletionsProviderPoolNormalizedIgnoresUnknownFields`를 추가한다. selected normalized path에서 unknown field가 있어도 strict ingress reject가 나지 않고, `RunRequest.Input`에는 supported option/tool fields만 존재하며 unknown key가 없음을 검증한다.

#### 수정 파일 및 체크리스트

- [x] `apps/edge/internal/openai/server_test.go`: normalized path fixture 추가 (`poolDispatchPath: string(edgeservice.ProviderPoolPathNormalized)`, `poolRunFrames`).
- [x] `apps/edge/internal/openai/server_test.go`: `fake.reqsSnapshot()[0].Input` assertions 추가.

#### 테스트 작성

- 작성한다. Assertion 목표: unknown field는 normalized native input에 전파되지 않고 supported field mapping은 유지된다.

#### 중간 검증

```bash
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'ProviderPoolNormalizedIgnoresUnknownFields' -count=1
```

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/edge/internal/openai/chat_handler.go` | SURFACE_FIELD-1 (envelope type/helper 추가, provider-pool lenient decode) |
| `apps/edge/internal/openai/server_test.go` | SURFACE_FIELD-2, SURFACE_FIELD-3 (regression test 2개 추가) |

## 최종 검증

```bash
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'ProviderPool.*Unknown|ProviderPoolRejectsResponseMode|MixedProviderPool' -count=1
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -count=1
```

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