<!-- task=m-openai-compatible-provider-passthrough-contract-sync/01_selector_metadata_surface plan=0 tag=API -->

# Plan - API Selector Metadata Surface

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

구현의 마지막 단계는 반드시 같은 디렉터리의 `CODE_REVIEW-cloud-G07.md`에서 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채우는 것이다. 검증 명령을 실행하고 실제 stdout/stderr를 붙여 넣은 뒤 active 파일을 유지한 채 리뷰 준비 상태로 보고한다.

선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막는 경우에만 review stub의 `사용자 리뷰 요청` 섹션을 정확한 근거와 함께 채우고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 외부 환경, secret, 서비스 준비, 일반 범위 조정, 검증 증거 공백은 사용자 리뷰 요청이 아니며 `검증 결과` 또는 `계획 대비 변경 사항`에 기록한다. `USER_REVIEW.md`, `complete.log`, archive 이동은 code-review-skill 전용이다.

## 배경

현재 Milestone의 route-contract 에픽은 caller-visible selector 없이 `model`만으로 provider passthrough 또는 normalized 경로를 정해야 한다. 그런데 Edge OpenAI handler에는 `metadata.iop_response_mode`, `passthrough+sideband`, `X-IOP-Response-Mode`, sideband envelope가 남아 있어 SDD의 selector 제거와 metadata 불변 조건을 동시에 위반한다. 이 작업은 공개 selector 표면을 제거하고, metadata가 route/response path를 바꾸지 않는다는 회귀 테스트와 문서 정리를 먼저 끝낸다.

## 사용자 리뷰 요청 흐름

구현 중 새 결정이 필요해 보여도 직접 질문하지 않는다. 선택된 Milestone lock decision이 실제 구현을 차단할 때만 `CODE_REVIEW-cloud-G07.md`의 `사용자 리뷰 요청` 섹션에 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 기록하고 code-review가 `USER_REVIEW.md` 작성 여부를 판단한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-provider-passthrough-contract-sync.md`
- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-provider-passthrough-contract-sync.md)
- Task ids:
  - `selector-remove`: caller-facing response mode selector parsing/branch/header/envelope/test fixture 제거
  - `metadata-known`: metadata는 route selector가 아니며 known key만 내부 문맥으로 사용
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/node/rules.md`
- `agent-ops/rules/project/domain/platform-common/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/node-smoke.md`
- `agent-test/local/platform-common-smoke.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-provider-passthrough-contract-sync.md`
- `agent-roadmap/sdd/routing-policy-model-orchestration/openai-compatible-provider-passthrough-contract-sync/SDD.md`
- `agent-contract/index.md`
- `agent-contract/outer/openai-compatible-api.md`
- `agent-contract/inner/edge-node-runtime-wire.md`
- `apps/edge/internal/openai/chat_handler.go`
- `apps/edge/internal/openai/responses_handler.go`
- `apps/edge/internal/openai/stream.go`
- `apps/edge/internal/openai/usage_metrics.go`
- `apps/edge/internal/openai/usage_metrics_test.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/edge/internal/openai/types.go`
- `apps/edge/internal/service/run_dispatch.go`
- `apps/edge/internal/service/model_queue.go`
- `apps/edge/internal/service/model_queue_test.go`
- `apps/node/internal/adapters/openai_compat/openai_compat.go`
- `apps/node/internal/adapters/openai_compat/openai_compat_test.go`
- `docs/openai-compatible-api-contract.md`
- `docs/openai-usage-grafana.md`

### SDD 기준

- SDD: `agent-roadmap/sdd/routing-policy-model-orchestration/openai-compatible-provider-passthrough-contract-sync/SDD.md`
- Status: `[승인됨]`, SDD lock 해제.
- Targeted Acceptance Scenarios:
  - `S01`: `selector-remove`, selector-like metadata는 arbitrary metadata이며 route selector가 아니고 body가 보존된다.
  - `S04`: `metadata-known`, known metadata keys는 내부 context로 복사되지만 original metadata는 변경되지 않고 arbitrary key는 route/response path를 바꾸지 않는다.
- Evidence Map 반영:
  - selector metadata negative test를 작성한다.
  - provider passthrough body immutability를 테스트한다.
  - known-key parsing과 arbitrary metadata의 route 비영향을 검증한다.
  - 공개 contract/docs와 non-archive code에서 selector/header/envelope 문자열이 남지 않는 `rg --sort path` 검증을 최종 검증에 둔다.

### 테스트 환경 규칙

- test_env: `local`.
- `agent-test/local/rules.md`를 읽었고 local smoke profile로 `edge-smoke`, `node-smoke`, `platform-common-smoke`가 존재함을 확인했다.
- 이 계획의 최종 검증은 Edge OpenAI handler와 service/node adapter 회귀를 직접 겨냥하므로 Go package 단위 테스트를 사용한다.
- 기본 `go test`는 `/config/.cache/go-build` permission 오류가 있어 실패했다. 같은 패키지 preflight에서 `GOCACHE=/tmp/iop-go-cache`를 지정한 명령은 `ok`로 동작했다.
- 최종 검증 명령은 fresh 실행이 필요하므로 `-count=1`을 사용하고, 검색 검증은 `rg --sort path`를 사용한다.

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

- `metadata.iop_response_mode`가 더 이상 provider-pool request를 reject하거나 sideband/transformed path를 선택하지 않는 negative test가 필요하다.
- Chat/Responses provider passthrough body에 caller metadata와 arbitrary unknown field가 그대로 남는지 검증하는 테스트가 필요하다.
- sideband envelope/header를 기대하는 기존 테스트들은 목표 동작과 반대가 되므로 제거하거나 pure passthrough usage metric 테스트로 바꿔야 한다.
- 내부 metric label `response_mode`는 허용되지만 caller selector 값에서 유래하면 안 된다. 기존 `passthrough+sideband`와 `transformed` label test는 갱신해야 한다.

### 심볼 참조

- 제거 또는 축소 대상:
  - `responseModeMetadataKey`: `chat_handler.go`, `responses_handler.go`, `stream.go`, `server_test.go`, `usage_metrics_test.go`.
  - `responseModePassthroughSideband`: `chat_handler.go`, `responses_handler.go`, `stream.go`, `server_test.go`, `usage_metrics_test.go`.
  - `responseModeTransformed`: `chat_handler.go`, `server_test.go`, `usage_metrics_test.go`.
  - `responseModeHeaderName`: `chat_handler.go`, `responses_handler.go`, `stream.go`, `server_test.go`, `usage_metrics_test.go`.
  - `parseResponseMode`, `responseModeWasExplicit`: `chat_handler.go`, `responses_handler.go`, `server_test.go`.
  - `tunnelChatCompletionPassthroughSideband`, `writeProviderTunnelSidebandStream`, `writeProviderTunnelSidebandResponse`, `responsesSideband*`: sideband helper and tests in `stream.go`, `responses_handler.go`, `server_test.go`, `usage_metrics_test.go`.
- Keep as internal-only:
  - `usageLabelsFor(... responseMode string)` and metric label name `response_mode`; label values must be derived from handler execution path, not caller metadata.

### 분할 판단

- Split decision policy를 적용했다. 같은 Milestone의 route-contract 전체 작업은 공개 selector/metadata 표면 제거와 provider-pool model route dispatch가 서로 다른 위험과 검증 단위를 갖는다.
- Shared task group: `agent-task/m-openai-compatible-provider-passthrough-contract-sync/`.
- Sibling tasks:
  - `01_selector_metadata_surface`: 이 계획. 선행 작업 없음.
  - `02+01_model_route_dispatch`: `01_selector_metadata_surface`의 `complete.log`를 기다린 뒤 Responses provider-pool의 selected execution path dispatch를 정리한다.
- 이 작업은 후속 dispatch 구현이 의존할 public surface를 먼저 안정화하므로 독립 리뷰 단위다.

### 범위 결정 근거

- 이 작업은 caller-facing selector, sideband envelope/header, metadata route 영향 제거에 한정한다.
- Responses provider-pool이 selected Ollama/CLI/native provider에서 normalized `RunRequest`를 쓰도록 고치는 작업은 `02+01_model_route_dispatch`에서 처리한다.
- Node OpenAI-compatible adapter의 raw tunnel 동작은 contract 검증 대상으로만 두고, 이 계획에서 adapter protocol을 변경하지 않는다.
- `agent-roadmap/archive/**`, `agent-task/archive/**`는 읽거나 수정하지 않는다.

### 빌드 등급

- `cloud-G07`: handler, stream writer, metrics, tests, public contract/docs까지 넓게 걸친 selector 제거이며 잘못 고치면 local unit test 일부를 통과해도 공개 API contract를 깨뜨릴 수 있다.

## 구현 체크리스트

- [ ] [API-1] Chat/Responses handler에서 caller-facing response mode selector parsing, rejection, branch, metadata injection을 제거한다.
- [ ] [API-2] Chat/Responses sideband response surface와 공개 header/envelope/event를 제거하고 pure passthrough 또는 normalized output만 남긴다.
- [ ] [API-3] selector-like metadata negative test, passthrough body immutability test, metric/doc 갱신, deterministic `rg` 검증을 완료한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [API-1] Caller Metadata Selector Removal

#### 문제

`metadata.iop_response_mode`가 provider-pool request에서 reject 또는 response path selector로 동작한다.

```go
// apps/edge/internal/openai/chat_handler.go:77
if dispatch.ProviderPool && responseModeWasExplicit(runMeta) {
	writeOpenAIError(w, http.StatusBadRequest, "invalid_request_error", "metadata.iop_response_mode is not supported for provider pool routes")
	return
}
responseMode, err := parseResponseMode(runMeta)
if err != nil {
	writeOpenAIError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
	return
}
```

```go
// apps/edge/internal/openai/responses_handler.go:60
if dispatch.ProviderPool && responseModeWasExplicit(runMeta) {
	writeOpenAIError(w, http.StatusBadRequest, "invalid_request_error", "metadata.iop_response_mode is not supported for provider pool routes")
	return
}
responseMode, err := parseResponseMode(runMeta)
```

#### 해결 방법

caller metadata에서 response mode를 읽는 코드를 삭제한다. handler의 response path는 `routeUsesProviderTunnel(dispatch)`와 provider-pool selected execution path에서만 정하고, internal metric label은 caller metadata와 무관한 상수로 전달한다.

```go
// after
runMeta := map[string]string(nil)
if req.Metadata != nil {
	var workspace string
	var err error
	workspace, runMeta, err = parseOpenAIMetadata(req.Metadata)
	if err != nil {
		writeOpenAIError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
		return
	}
	if workspace != "" {
		runReq.Workspace = workspace
	}
}
```

`strict` output format은 normalized assembly로 처리하되 `metadata.iop_response_mode`나 `X-IOP-Response-Mode`를 쓰지 않는다.

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

- [ ] `apps/edge/internal/openai/chat_handler.go`: `parseResponseMode`, `responseModeWasExplicit`, caller metadata rejection/branch 제거.
- [ ] `apps/edge/internal/openai/responses_handler.go`: provider-pool selector rejection과 responseMode switch 제거.
- [ ] `apps/edge/internal/openai/stream.go`: tunnel metadata에 `iop_response_mode`를 쓰지 않도록 조정.
- [ ] `apps/edge/internal/openai/usage_metrics.go`: 내부 metric label 값이 caller metadata에서 파생되지 않음을 보장한다.

#### 테스트 작성

- `apps/edge/internal/openai/server_test.go`
  - 기존 `TestChatCompletionsProviderPoolRejectsResponseMode`, `TestResponsesProviderPoolRejectsResponseMode`는 reject 테스트가 아니라 ignored metadata negative test로 변경한다.
  - Chat/Responses provider-pool request에 `metadata: {"iop_response_mode":"passthrough+sideband"}`가 있어도 400이 아니며 route/response path와 provider body를 바꾸지 않는지 검증한다.

#### 중간 검증

```bash
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'Test(ChatCompletionsProviderPool|ResponsesProviderPool).*ResponseMode|Test.*Metadata.*Route|Test.*PreservesUnknownFields' -count=1
```

예상 결과: 관련 테스트가 모두 PASS하고 selector metadata로 인한 400 또는 sideband/transformed path가 없다.

### [API-2] Sideband Surface Removal

#### 문제

provider passthrough path에서 sideband header, SSE event, envelope가 공개 response surface에 섞인다.

```go
// apps/edge/internal/openai/stream.go:341
func tunnelChatCompletionPassthroughSideband(w http.ResponseWriter, r *http.Request, svc runService, raw json.RawMessage, dispatch service.ModelDispatch, req chatCompletionRequest, stream bool) {
	result, err := submitChatCompletionTunnel(r.Context(), svc, raw, dispatch, req, responseModePassthroughSideband)
```

```go
// apps/edge/internal/openai/responses_handler.go:491
w.Header().Set(responseModeHeaderName, responseModePassthroughSideband)
w.Header().Set("Content-Type", "text/event-stream")
```

#### 해결 방법

public response는 provider raw passthrough 또는 normalized IOP output만 남긴다. sideband-only helper, envelope type, response header write, SSE `event: iop.sideband` emission, body metadata injection을 제거한다. Metrics/usage observation이 sideband assembler에 의존한다면 내부 관찰 helper만 남기고 public write path와 분리한다.

```go
// after
if routeUsesProviderTunnel(dispatch) {
	tunnelChatCompletionPassthrough(w, r, svc, raw, dispatch, req, stream)
	return
}
```

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

- [ ] `apps/edge/internal/openai/stream.go`: sideband stream/non-stream writers와 public envelope constants 제거 또는 내부 observation helper로 축소.
- [ ] `apps/edge/internal/openai/responses_handler.go`: `responsesSideband*` helper, sideband header/event/body injection 제거.
- [ ] `apps/edge/internal/openai/chat_handler.go`: transformed mode label/header/body injection 제거.
- [ ] `apps/edge/internal/openai/usage_metrics_test.go`: sideband emission 기대 테스트를 pure passthrough usage metric 테스트로 변경하거나 삭제한다.
- [ ] `apps/edge/internal/openai/server_test.go`: sideband header/event/envelope fixture를 제거한다.

#### 테스트 작성

- 기존 sideband 테스트는 목표 동작과 반대이므로 삭제 또는 pure passthrough metric 테스트로 전환한다.
- `TestProviderPoolStandardResponseNoSideband`류 테스트는 유지하고, Chat/Responses 모두 no `iop_sideband`, no `iop_response_mode`, no `X-IOP-Response-Mode`를 검증하도록 보강한다.

#### 중간 검증

```bash
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'Test.*Sideband|Test.*NoSideband|Test.*UsageMetrics' -count=1
```

예상 결과: sideband를 기대하던 테스트는 갱신된 기대값으로 PASS하고 public response에 IOP sideband marker가 없다.

### [API-3] Metadata Negative Tests And Docs

#### 문제

contract는 metadata가 path selector가 아니라고 말하지만 code/test/docs에는 selector fixture와 stale metric 설명이 남아 있다.

```markdown
// docs/openai-usage-grafana.md:21
response_mode label examples are still documented with old values.
```

#### 해결 방법

docs와 tests를 SDD/contract에 맞춘다. `metadata`의 known key는 내부 context로 읽되 original provider passthrough body는 보존하고 arbitrary metadata key는 route/response path를 바꾸지 않는다고 명시한다. `docs/openai-usage-grafana.md`의 `response_mode` 설명은 internal execution label로 정리하고 caller input이나 API selector로 오해될 표현을 제거한다.

```markdown
<!-- after -->
`response_mode` is an internal execution label such as `passthrough` or `normalized`; callers cannot set it through OpenAI metadata.
```

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

- [ ] `apps/edge/internal/openai/server_test.go`: selector-like metadata ignored 테스트와 passthrough body immutability assertion 추가.
- [ ] `apps/edge/internal/openai/usage_metrics_test.go`: internal metric label 값 갱신.
- [ ] `docs/openai-compatible-api-contract.md`: 이미 model-driven/metadata-not-selector이면 변경하지 않는다.
- [ ] `docs/openai-usage-grafana.md`: internal metric label 설명을 최신화한다.
- [ ] `agent-contract/outer/openai-compatible-api.md`: 이미 contract가 맞으면 변경하지 않는다. 변경 시에는 contract rule에 맞게 최소 수정한다.

#### 테스트 작성

- Regression test required.
- Chat와 Responses 각각 provider-pool passthrough request body에 arbitrary metadata와 `iop_response_mode`가 남아 있는지 확인한다.
- normalized path에서는 arbitrary metadata가 route selection을 바꾸지 않는지 확인한다.

#### 중간 검증

```bash
bash -lc 'rg --sort path "metadata\\.iop_response_mode|iop_response_mode|passthrough\\+sideband|X-IOP-Response-Mode|iop_sideband|event: iop\\.sideband|iop\\.chat\\.passthrough_sideband|iop\\.responses\\.sideband" apps agent-contract docs README.md -g "!**/archive/**"; status=$?; [ "$status" -eq 1 ]'
```

예상 결과: exit status 0, 출력 없음. 내부 metric label `response_mode`는 이 검색 대상에 포함하지 않는다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/edge/internal/openai/chat_handler.go` | API-1, API-2 |
| `apps/edge/internal/openai/responses_handler.go` | API-1, API-2 |
| `apps/edge/internal/openai/stream.go` | API-1, API-2 |
| `apps/edge/internal/openai/usage_metrics.go` | API-1, API-3 |
| `apps/edge/internal/openai/server_test.go` | API-1, API-2, API-3 |
| `apps/edge/internal/openai/usage_metrics_test.go` | API-2, API-3 |
| `docs/openai-usage-grafana.md` | API-3 |
| `docs/openai-compatible-api-contract.md` | API-3 |
| `agent-contract/outer/openai-compatible-api.md` | API-3 |

## 최종 검증

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

예상 결과: Edge OpenAI handler 테스트 PASS.

```bash
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/service ./apps/node/internal/adapters/openai_compat -count=1
```

예상 결과: service/provider dispatch와 node adapter 회귀 테스트 PASS.

```bash
bash -lc 'rg --sort path "metadata\\.iop_response_mode|iop_response_mode|passthrough\\+sideband|X-IOP-Response-Mode|iop_sideband|event: iop\\.sideband|iop\\.chat\\.passthrough_sideband|iop\\.responses\\.sideband" apps agent-contract docs README.md -g "!**/archive/**"; status=$?; [ "$status" -eq 1 ]'
```

예상 결과: exit status 0, 출력 없음.

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