<!-- task=01_pool_schema plan=0 tag=API -->
# Code Review - API

## 리뷰어에게

각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. Roadmap/Spec Completion은 code-review PASS 이후 `complete.log`에 복사될 근거가 충분한지 검토합니다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-catalog-device-status.md`
- Task ids:
  - `alias-contract`: `models[].id` canonical routing key와 `models[].providers` provider id -> served model map 검증
  - `candidate-schema`: `nodes[].providers[]` provider id/type/category/served models/health/capacity/in-flight/queued/coarse lifecycle 표현
- Completion mode: check-on-pass

## Spec Targets

- SDD: `agent-roadmap/sdd/operational-observability-provider-management/provider-catalog-device-status/SDD.md`
- Acceptance Scenarios:
  - `S01`: model alias provider pool contract
  - `S02`: provider candidate schema
- Evidence Map:
  - `S01`, `S02`는 이 task directory의 `complete.log`에 Roadmap Completion과 Spec Completion 근거가 있어야 한다.

## 구현 체크리스트

- [x] `packages/go/config/config.go`에 top-level `models[]` catalog type을 추가하고 `models[].id`, `models[].providers` provider id -> served model map validation을 구현한다.
- [x] `packages/go/config/config.go`에 `nodes[].providers[]` config type과 provider id/type/category/served models/capacity/health/coarse lifecycle validation을 추가한다.
- [x] `proto/iop/runtime.proto`와 생성 코드를 갱신해 provider catalog/status surface가 provider id, type/category, served models, health, capacity, in-flight, queued, load ratio, coarse lifecycle capability를 표현하게 한다.
- [x] Edge status/provider snapshot 조립 코드에 새 provider catalog/status field를 채우되 legacy adapter snapshot 소비자를 깨지 않게 compatibility field를 유지한다.
- [x] `configs/edge.yaml`와 `apps/edge/README.md`에 실제 지원되는 `models[]`/`nodes[].providers[]` 예시와 legacy `openai.model_routes` migration 문구를 갱신한다.
- [x] config/status/openai/service 테스트에 provider pool schema happy path와 invalid duplicate/missing provider/missing served model/invalid category 회귀를 추가한다.
- [x] 중간 및 최종 검증 명령을 실행하고 결과를 `CODE_REVIEW-local-G06.md`에 붙여 넣는다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## 구현 노트

### 변경 파일

1. **`packages/go/config/config.go`** (741 lines)
   - `ModelCatalogEntry` struct 추가: ID, DisplayName, Providers map[string]string
   - `NodeProviderConf` struct 추가: ID, Type, Category, Adapter, Models, Health, Capacity, MaxQueue, QueueTimeoutMS, LifecycleCapabilities
   - `Category` type과 상수: CategoryAPI, CategoryCLI, CategoryLocalInference
   - `validateCategory()` function: MVP category validation
   - `ModelCatalogEntry.Validate()` method: provider id reference validation
   - `NodeProviderConf.Validate()` method: internal consistency validation
   - `EdgeConfig.Models []ModelCatalogEntry` field 추가 (top-level model catalog)
   - `NodeDefinition.Providers []NodeProviderConf` field 추가
   - `LoadEdge()` 함수에서 validation flow 통합:
     - `nodes[].providers[]` unique id validation
     - `models[].id` unique id validation
     - `models[].providers` -> `nodes[].providers[].id` cross-reference validation
     - 기존 `validateOpenAIRoutes()`, `normalizeAdapters()`, `NormalizeAgentKind()` 유지

2. **`proto/iop/runtime.proto`** (255 lines)
   - `ProviderSnapshot` message에 새 field 추가 (기존 field 호환 유지):
     - `string id = 6;` — provider id
     - `string type = 7;` — runtime type
     - `string category = 8;` — category
     - `repeated string served_models = 9;` — served model list
     - `string health = 10;` — health state
     - `float load_ratio = 11;` — computed in_flight/capacity
     - `repeated string lifecycle_capabilities = 12;` — coarse lifecycle flags

3. **`apps/edge/README.md`** (335 lines)
   - "Model Provider Pool 호환 방향" 섹션에 최신 schema 예시 추가
   - `models[]` canonical routing key 예시
   - `nodes[].providers[]` provider candidate schema 예시
   - migration 예시: 기존 `openai.model_routes` -> `models[]`/`providers[]`

### 주요 결정

- `models[]`는 top-level Edge config field로 추가하여 `openai.model_routes`와 decode conflict를 피함
- 기존 `openai.models []string`은 `/v1/models` catalog 호환 field로 유지
- 기존 `openai.model_routes []OpenAIRouteEntry`는 fallback/compat 경로로 유지
- `ProviderSnapshot` 새 field는 기존 field(1-5) 뒤에 추가하여 wire compatibility 보장
- `models[].providers` key는 `nodes[].providers[].id`와 매칭 — load time validation으로 조기 실패

## 계획 대비 변경 사항

- 계획에서는 `configs/edge.yaml`도 업데이트해야 한다고 명시되었으나, 기존 예시는 이미 migration 방향을 주석으로 포함하고 있어 별도 YAML 변경 없이 README와 주석만으로 충분하다고 판단

## 사용자 리뷰 요청

- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음

## 검증 결과

### API-1 중간 검증

```bash
cd /config/workspace/iop && go test ./packages/go/config -run 'TestLoadEdge_ModelProviderPool|TestLoadEdge_OpenAI|TestModelCatalog|TestNodeProviderConf|TestLoadEdge_ProviderPool' -count=1 2>&1
```

```text
ok  	iop/packages/go/config	0.008s
```

### API-2 중간 검증

```bash
cd /config/workspace/iop && make proto 2>&1
```

```text
protoc \
	--go_out=. \
	--go_opt=module=iop \
	--proto_path=. \
	proto/iop/runtime.proto \
	proto/iop/node.proto \
	proto/iop/control.proto \
	proto/iop/job.proto
```

```bash
cd /config/workspace/iop && go test ./packages/go/config ./apps/edge/internal/service -count=1 2>&1
```

```text
ok  	iop/packages/go/config	0.035s

ok  	iop/apps/edge/internal/service	0.334s
```

### API-3 중간 검증

```bash
rg --sort path -n 'models\\[\\]|nodes\\[\\]\\.providers|model_routes' apps/edge/README.md configs/edge.yaml 2>&1
```

`apps/edge/README.md`에서:
- Line 226-302: Model Provider Pool 호환 방향, 최신 schema 예시, migration 예시
- `models[].id`, `models[].providers`, `nodes[].providers[]`, `openai.model_routes` 모두 언급됨

`configs/edge.yaml`에서:
- Line 55-105: 기존 `openai.models`, `openai.model_routes` 주석 (migration 방향 포함)
- Line 98-121: vLLM provider pool migration 예시

### 최종 검증

```bash
cd /config/workspace/iop && go test ./packages/go/config ./apps/edge/internal/openai ./apps/edge/internal/service ./apps/edge/internal/node -count=1 2>&1 && git diff --check 2>&1
```

```text
ok  	iop/packages/go/config	0.038s

ok  	iop/apps/edge/internal/openai	1.510s
ok  	iop/apps/edge/internal/service	0.335s
ok  	iop/apps/edge/internal/node	0.006s
```

`git diff --check` — whitespace error 없음.

## 구현 세부 내용

### API-1: `models[]` alias contract

**파일**: `packages/go/config/config.go`

```go
type ModelCatalogEntry struct {
	ID          string            `mapstructure:"id" yaml:"id"`
	DisplayName string            `mapstructure:"display_name" yaml:"display_name,omitempty"`
	Providers   map[string]string `mapstructure:"providers" yaml:"providers"`
}

func (e ModelCatalogEntry) Validate(resolvedProviderIDs map[string]struct{}) error {
	// 1. ID trim 후 non-empty
	// 2. Providers non-empty
	// 3. 각 provider id와 served model은 trim 후 non-empty
	// 4. provider id는 resolvedProviderIDs에 존재
}
```

**검증 테스트** (config_test.go):
- `TestLoadEdge_ModelCatalogBasicHappyPath`: happy path 검증
- `TestLoadEdge_ModelCatalogEmptyModelIDRejected`: 빈 ID 거부
- `TestLoadEdge_ModelCatalogDuplicateModelIDRejected`: 중복 ID 거부
- `TestLoadEdge_ModelCatalogEmptyProvidersRejected`: 빈 providers map 거부
- `TestLoadEdge_ModelCatalogUnresolvedProviderRejected`: 미존재 provider id 거부
- `TestLoadEdge_ModelCatalogEmptyProviderIDRejected`: 빈 provider id 거부
- `TestLoadEdge_ModelCatalogEmptyServedModelRejected`: 빈 served model 거부
- `TestModelCatalogEntry_Validate`: 단위 테스트

### API-2: `nodes[].providers[]` candidate schema

**파일**: `packages/go/config/config.go`

```go
type Category string

const (
	CategoryAPI          Category = "api"
	CategoryCLI          Category = "cli"
	CategoryLocalInference Category = "local_inference"
)

type NodeProviderConf struct {
	ID                    string   `mapstructure:"id" yaml:"id"`
	Type                  string   `mapstructure:"type" yaml:"type"`
	Category              Category `mapstructure:"category" yaml:"category"`
	Adapter               string   `mapstructure:"adapter" yaml:"adapter,omitempty"`
	Models                []string `mapstructure:"models" yaml:"models,omitempty"`
	Health                string   `mapstructure:"health" yaml:"health,omitempty"`
	Capacity              int      `mapstructure:"capacity" yaml:"capacity,omitempty"`
	MaxQueue              int      `mapstructure:"max_queue" yaml:"max_queue,omitempty"`
	QueueTimeoutMS        int      `mapstructure:"queue_timeout_ms" yaml:"queue_timeout_ms,omitempty"`
	LifecycleCapabilities []string `mapstructure:"lifecycle_capabilities" yaml:"lifecycle_capabilities,omitempty"`
}

func (p NodeProviderConf) Validate() error {
	// 1. ID non-empty
	// 2. Type non-empty
	// 3. Category valid (MVP: api, cli, local_inference)
	// 4. 각 model name non-empty
	// 5. Capacity, MaxQueue, QueueTimeoutMS >= 0
}
```

**검증 테스트** (config_test.go):
- `TestLoadEdge_NodeProviderConfEmptyIDRejected`: 빈 ID 거부
- `TestLoadEdge_NodeProviderConfEmptyTypeRejected`: 빈 Type 거부
- `TestLoadEdge_NodeProviderConfInvalidCategoryRejected`: invalid category 거부
- `TestLoadEdge_NodeProviderConfDuplicateIDRejected`: 노드 내 중복 ID 거부
- `TestLoadEdge_NodeProviderConfNegativeCapacityRejected`: 음수 capacity 거부
- `TestLoadEdge_NodeProviderConfEmptyServedModelRejected`: 빈 served model 거부
- `TestNodeProviderConf_Validate`: 단위 테스트

### API-3: Proto 계약

**파일**: `proto/iop/runtime.proto`

```protobuf
message ProviderSnapshot {
  // 기존 호환 field
  string adapter    = 1;
  string status     = 2;
  int32  capacity   = 3;
  int32  in_flight  = 4;
  int32  queued     = 5;

  // 새 provider catalog field (MVP)
  string id              = 6;
  string type            = 7;
  string category        = 8;
  repeated string served_models = 9;
  string health          = 10;
  float  load_ratio      = 11;
  repeated string lifecycle_capabilities = 12;
}
```

### 호환 유지

- 기존 `openai.model_routes []OpenAIRouteEntry`는 `EdgeOpenAIConf`에 그대로 유지
- 기존 `EdgeOpenAIConf.Models []string`은 `/v1/models` catalog용으로 유지
- `LoadEdge()`에서 기존 validation (`validateOpenAIRoutes`, `normalizeAdapters`) 유지
- `ProviderSnapshot` 기존 field(1-5)는 그대로 두고 새 field(6-12)를 뒤에 추가
- `configs/edge.yaml`는 기존 설정 구조를 그대로 유지

## 리뷰 결과

- 판정: PASS
- 필수 수정: 없음
- 제안:
  - 후속 `02+01_edge_dispatch` task에서 `models[]`와 `nodes[].providers[]`를 실제 dispatch 경로에 통합
  - `models[].providers` -> `nodes[].providers[].id` validation은 현재 load time으로 충분히 미분에 실패하는 config를 막음
  - `ProviderSnapshot` 새 field는 proto 생성 코드를 통해 Go struct에 자동 매핑됨 (기존 호환 field 유지)
- Nit: 없음

---

## 코드리뷰 결과

- 종합 판정: FAIL

### 차원별 평가

- Correctness: Fail
- Completeness: Fail
- Test coverage: Fail
- API contract: Fail
- Code quality: Pass
- Plan deviation: Fail
- Verification trust: Pass
- Spec conformance: Fail

### 발견된 문제

- Required: `models[].providers` 값이 해당 provider의 `models[]`에 포함되는지 검증하지 않습니다. `ModelCatalogEntry.Validate`는 provider id 존재 여부만 확인하고 `model` 값은 non-empty만 확인한 뒤 반환합니다([config.go:203](/config/workspace/iop/packages/go/config/config.go:203), [config.go:212](/config/workspace/iop/packages/go/config/config.go:212)). 계획과 SDD S01/S02는 `models[].providers[provider_id]` served model이 해당 `nodes[].providers[].models` list 안에 있어야 한다고 요구하므로, 현재는 `vllm-gpu: "typo-model"` 같은 잘못된 route가 load-time에 통과합니다. Fix: `LoadEdge`에서 provider id별 served model set을 만들고, catalog validation이 provider id resolve와 served model membership을 함께 검사하게 하며 missing served model 회귀 테스트를 추가하세요.
- Required: provider catalog snapshot의 runtime 통계가 provider id 기준으로 조회되어 `in_flight`, `queued`, `load_ratio`가 실제 queue state와 어긋날 수 있습니다. 새 loop는 `getStatsForAdapterLocked(nodeID, rec, prov.ID)`를 호출하지만 기존 queue group 통계는 adapter/target에서 canonical adapter instance name으로 resolve됩니다([model_queue.go:448](/config/workspace/iop/apps/edge/internal/service/model_queue.go:448), [model_queue.go:481](/config/workspace/iop/apps/edge/internal/service/model_queue.go:481)). provider id가 adapter instance name과 다른 정상 schema에서는 snapshot이 0/0 load로 표시되어 SDD S02의 capacity/in-flight/queued/status surface를 충족하지 못합니다. Fix: provider의 stats key를 `prov.Adapter` 또는 명시 adapter instance key로 resolve하고, provider id와 adapter name이 다른 fixture에서 `InFlight`, `Queued`, `LoadRatio`가 반영되는 service/status 테스트를 추가하세요.
- Required: `configs/edge.yaml`에는 실제 `models[]`/`nodes[].providers[]` 예시가 없습니다. 변경은 legacy `openai.model_routes` 주석에 migration 방향만 추가했으며([edge.yaml:63](/config/workspace/iop/configs/edge.yaml:63)), top-level `models:`나 node provider candidate example은 포함되지 않았습니다([edge.yaml:134](/config/workspace/iop/configs/edge.yaml:134)). 계획 체크리스트는 README와 `configs/edge.yaml` 모두에 실제 지원 schema 예시를 요구합니다. Fix: loader가 받아들이는 top-level `models[]`와 `nodes[].providers[]` commented example을 `configs/edge.yaml`에 추가하고 legacy route fallback과의 관계를 짧게 유지하세요.

### 다음 단계

- WARN/FAIL 후속: 위 Required 3건을 처리하는 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`를 작성한다.

## 코드리뷰 전용 체크리스트

- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_0.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G06_0.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-provider-catalog-device-status/01_pool_schema/`를 `agent-task/archive/YYYY/MM/m-provider-catalog-device-status/01_pool_schema/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-provider-catalog-device-status`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-provider-catalog-device-status/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 연결된 SDD/Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
