<!-- task=m-provider-resource-admission-ownership-alignment/04_provider_pool_policy_config plan=0 tag=PROVIDER_POLICY_CONFIG -->

# Provider-pool 공통 Queue Policy 설정 계획

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

구현과 검증을 완료한 뒤 `CODE_REVIEW-local-G06.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 stdout/stderr로 채우고 active 파일을 그대로 둔 채 리뷰 준비 상태를 보고한다. 종결 판정, log rename, `complete.log`, archive 이동은 code-review 전용이다. 선택된 Milestone의 `구현 잠금 > 결정 필요`가 구현을 막을 때만 review stub의 `사용자 리뷰 요청`에 정확한 연결 근거를 기록하고 멈춘다. 직접 사용자 질문·선택지·`request_user_input`, `USER_REVIEW.md`, archive log, `complete.log` 작성은 금지한다. 환경/secret/service blocker, 일반 범위 조정, 후속 에이전트가 해소할 수 있는 evidence gap은 사용자 리뷰 요청이 아니다.

## 배경

현재 provider-pool queue policy는 `nodes[].providers[]`에 남아 있고 runtime이 후보 순서상 첫 값을 정책으로 선택한다. SDD D01/S06은 Edge root `provider_pool`을 canonical owner로 두고, canonical 값이 없을 때 동일한 legacy provider 값만 승격하며 충돌은 load 단계에서 거부하도록 확정했다. 이 하위 작업은 schema·migration·refresh diff와 사람용 설정 예시를 먼저 고정하고 runtime admission은 후속 05에서 연결한다.

## 사용자 리뷰 요청 흐름

선택된 Milestone lock 결정만 active `CODE_REVIEW-local-G06.md`의 `사용자 리뷰 요청` 템플릿에 기록한다. 구현 중 직접 사용자 prompt는 금지하며 code-review가 검증과 실제 `USER_REVIEW.md` 작성을 소유한다.

## 분석 결과

### 읽은 파일

- 규칙/라우팅: `AGENTS.md`, `agent-ops/rules/project/rules.md`, `agent-ops/rules/common/rules-roadmap.md`, `agent-ops/rules/common/rules-agent-spec.md`, `agent-ops/skills/common/router.md`, `agent-ops/skills/common/plan/SKILL.md`
- 로드맵/SDD: `agent-roadmap/current.md`, `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-resource-admission-ownership-alignment.md`, `agent-roadmap/sdd/operational-observability-provider-management/provider-resource-admission-ownership-alignment/SDD.md`
- 계약/스펙: `agent-contract/index.md`, `agent-contract/inner/edge-config-runtime-refresh.md`, `agent-contract/inner/edge-node-runtime-wire.md`, `agent-contract/inner/control-plane-edge-wire.md`, `agent-spec/index.md`, `agent-spec/runtime/provider-pool-config-refresh.md`, `agent-spec/runtime/edge-node-execution.md`, `agent-spec/runtime/control-plane-operations.md`
- 설정 소스: `packages/go/config/config.go`, `edge_types.go`, `provider_types.go`, `adapter_types.go`, `load.go`, `normalize.go`, `validate.go`
- refresh 소스: `apps/edge/internal/configrefresh/classify.go`, `request.go`, `result.go`
- 설정/가이드: `configs/edge.yaml`, `apps/edge/README.md`
- 테스트: `packages/go/config/provider_catalog_config_test.go`, `provider_catalog_validation_config_test.go`, `edge_openai_config_test.go`, `apps/edge/internal/configrefresh/classify_test_support_test.go`, `provider_classify_test.go`, `path_refresh_test.go`
- 소비 경계 확인: `apps/edge/internal/service/model_queue_types.go`, `model_queue_admission.go`, `provider_resolution.go`, `service.go`, `apps/edge/internal/bootstrap/runtime.go`
- 테스트 규칙/프로필: `agent-test/local/rules.md`, `agent-test/local/edge-smoke.md`, `agent-test/local/platform-common-smoke.md`, `agent-test/local/testing-smoke.md`

### SDD 기준

- SDD: `agent-roadmap/sdd/operational-observability-provider-management/provider-resource-admission-ownership-alignment/SDD.md`, 상태 `[승인됨]`.
- Acceptance Scenario S06 / Task `queue-ownership`: root `provider_pool.max_queue`, `provider_pool.queue_timeout_ms`, legacy 동일값 승격, legacy 충돌 거부, first-candidate 금지를 요구한다.
- Evidence Map의 config/queue deterministic test 요구 중 schema·compatibility·refresh classification을 이 계획에 고정한다. 실제 Edge 전체 pending admission 증거는 05가 완성한다.

### 테스트 환경 규칙

- `test_env=local`; `agent-test/local/rules.md`, `platform-common-smoke.md`, `edge-smoke.md`, `testing-smoke.md`를 읽었다.
- 공통 설정 변경 필수 기준인 fresh `go test ./packages/go/...`, 소비 Edge 패키지 회귀, 실제 `edge config check`를 적용한다. 캐시 결과는 허용하지 않고 별도 `/tmp` GOCACHE와 `-count=1`을 쓴다.
- runner는 `/config/workspace/iop`, branch `main`, HEAD `f8703246900a`, Linux aarch64, Go 1.26.2이며 기존 dirty worktree를 보존한다.
- 기준선에서 `GOCACHE=/tmp/iop-go-cache-provider-policy-plan go test -count=1 ./packages/go/config ./apps/edge/internal/configrefresh`와 `GOCACHE=/tmp/iop-go-cache-provider-policy-plan go run ./apps/edge/cmd/edge --config configs/edge.yaml config check`가 PASS했다.

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

- root `provider_pool` decode/default/negative/explicit zero timeout 테스트가 없다.
- canonical 부재 시 동일 legacy pair 승격, provider 순서와 무관한 충돌 거부, canonical 우선 테스트가 없다.
- refresh classifier가 root policy를 `applied`로 보고 legacy provider queue field만 바뀐 경우 effective policy 기준으로 판정하는 테스트가 없다.
- 설정 예시와 README는 아직 provider별 queue owner를 설명한다.

### 심볼 참조

- 새 public config symbol `EdgeProviderPoolConf`와 default constants를 추가한다.
- `providerKey.MaxQueue`, `providerKey.QueueTimeoutMS` 제거 시 참조는 `apps/edge/internal/configrefresh/classify.go`의 struct/build/appended diff와 provider classifier tests다.
- `NodeProviderConf.MaxQueue`/`QueueTimeoutMS`는 compatibility field이므로 제거하거나 Node mapper/adapter-local 소비자를 변경하지 않는다.

### 분할 판단

- split decision policy를 먼저 평가했다. schema/migration/refresh diff는 platform-common 소유 경계이고 runtime queue 동시성 변경과 독립 검증 가능하므로 04로 분리한다.
- 공유 task group은 `m-provider-resource-admission-ownership-alignment`다.
- `04_provider_pool_policy_config`는 독립 작업이다.
- `05+03,04_provider_pool_policy_admission`은 active 03 follow-up과 이 04의 `complete.log`를 기다린다.
- `06+05_refresh_consistency`는 05를 기다린다.
- `07+03_snapshot_source`는 active 03의 live candidate resolver를 기다리며 04/05/06과 별도 snapshot 관측 경계에서 진행한다.

### 범위 결정 근거

- admission, Service runtime snapshot, queue timer는 05/06에서 수정한다.
- adapter-local `max_queue`/`queue_timeout_ms`, protobuf payload와 Node mapper는 provider-pool canonical owner와 별개인 compatibility/defense-in-depth 경계로 유지한다.
- 계약/agent-spec 본문 동기화는 Milestone의 별도 `contract-spec-sync` Task로 남기되, 현재 사람용 `configs/edge.yaml`과 `apps/edge/README.md`의 잘못된 설정 예시는 schema 변경과 함께 바로 고친다.

### 빌드 등급

- `local-G06`: 명시된 SDD 결정에 따른 bounded config schema/migration이며 package tests와 config check로 결정론적으로 검증 가능하다.

## 의존 관계 및 구현 순서

1. root effective policy type/default를 추가한다.
2. load 단계에서 canonical presence를 먼저 판정하고 legacy effective pair를 순서 독립적으로 승격/거부한다.
3. refresh classifier를 effective root policy diff로 전환한다.
4. config 예시와 README를 canonical 형태로 맞춘 뒤 fresh 회귀를 실행한다.

## 구현 체크리스트

- [x] Edge root `provider_pool` effective schema와 기본값/validation을 추가하고 explicit `queue_timeout_ms: 0`을 no-timeout으로 보존한다.
- [x] canonical 부재 시 동일 legacy provider policy만 승격하고 충돌은 provider 순서와 무관하게 명시적 validation error로 거부하며 canonical 존재 시 legacy 값을 정책 결정에서 제외한다.
- [x] config refresh diff를 effective `provider_pool` policy의 live-applied path로 전환하고 provider별 legacy queue field diff가 canonical policy로 보고되지 않게 한다.
- [x] `configs/edge.yaml`과 `apps/edge/README.md` 예시를 root owner/legacy compatibility 의미로 갱신하고 fresh config/package 검증을 통과시킨다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [PROVIDER_POLICY_CONFIG-1] Canonical schema와 presence-aware legacy migration

#### 문제

`packages/go/config/edge_types.go:7-27`의 `EdgeConfig`에는 provider-pool 공통 policy가 없다. `provider_types.go:62-66`은 queue field를 provider 실행 속성처럼 노출하고, `load.go:24-120`은 provider별 값의 충돌이나 canonical 승격을 처리하지 않는다.

#### 해결 방법

`EdgeConfig`에 effective value를 보유하는 `ProviderPool EdgeProviderPoolConf`를 추가하고 기본값을 config package의 exported constants로 한 번만 정의한다. `LoadEdge`는 Viper raw config의 실제 key presence를 사용해 canonical과 legacy를 구분한다.

Before (`edge_types.go:7-27`):

```go
type EdgeConfig struct {
	Edge    EdgeInfo       `mapstructure:"edge" yaml:"edge"`
	Server  EdgeServerConf `mapstructure:"server" yaml:"server"`
	// ...
	Models []ModelCatalogEntry `mapstructure:"models" yaml:"models,omitempty"`
}
```

After:

```go
const (
	DefaultProviderPoolMaxQueue       = 16
	DefaultProviderPoolQueueTimeoutMS = 30000
)

type EdgeProviderPoolConf struct {
	MaxQueue       int `mapstructure:"max_queue" yaml:"max_queue"`
	QueueTimeoutMS int `mapstructure:"queue_timeout_ms" yaml:"queue_timeout_ms"`
}

type EdgeConfig struct {
	// ...
	ProviderPool EdgeProviderPoolConf `mapstructure:"provider_pool" yaml:"provider_pool"`
}
```

Migration helper는 다음 순서를 고정한다.

1. `provider_pool.max_queue` 또는 `provider_pool.queue_timeout_ms`가 source config에 있으면 missing canonical field만 default로 채우고 legacy provider 값은 비교/선택하지 않는다.
2. canonical이 없으면 `nodes[].providers[]` 중 현재 runtime에서 policy를 구성하는 legacy pair만 presence-aware로 수집해 아래 표대로 effective pair로 정규화한다.
3. pair가 없으면 defaults, 모두 같으면 그 pair를 승격, 둘 이상이면 provider id와 값이 포함된 deterministic error를 반환한다.
4. canonical/legacy 음수는 거부하고 explicit timeout 0은 no-timeout으로 유지한다.

| source key presence | effective `max_queue` | effective `queue_timeout_ms` | legacy promotion candidate |
|---|---:|---:|---|
| canonical key 없음 | 16 | 30000 | 해당 없음 |
| canonical `max_queue: 0`, timeout 미지정 | 16 | 30000 | 해당 없음 |
| canonical max 미지정, `queue_timeout_ms: 0` | 16 | 0 | 해당 없음 |
| legacy max/timeout 모두 미지정 또는 timeout만 `0` | 16 | 30000 | 아니오 |
| legacy `max_queue > 0`, timeout 미지정 또는 `0` | legacy max | 0 | 예 |
| legacy max 미지정 또는 `0`, `queue_timeout_ms > 0` | 16 | legacy timeout | 예 |
| legacy max/timeout 모두 양수 | legacy max | legacy timeout | 예 |

canonical의 `max_queue: 0`은 default 16으로 정규화하고, canonical key가 하나라도 있으면 legacy 값은 충돌 검증과 정책 선택에서 모두 제외한다. legacy timeout-only `0`은 기존 runtime에서 policy를 구성하지 않았으므로 승격 후보가 아니다.

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

- [x] `packages/go/config/edge_types.go`: root policy type와 defaults, `EdgeConfig.ProviderPool`을 추가한다.
- [x] `packages/go/config/load.go`: raw key presence와 provider id를 보존하는 migration/validation helper를 `LoadEdge` validation 앞에 연결한다.
- [x] `packages/go/config/provider_types.go`: 두 queue field를 legacy provider-pool compatibility field로 명확히 주석화한다.

#### 테스트 작성

- `packages/go/config/provider_catalog_config_test.go`에 `TestLoadEdge_ProviderPoolPolicyCanonical`, `...LegacyIdenticalPromotion`, `...CanonicalOverridesLegacyConflict`, `...OrderIndependentConflict`, `...Defaults`, `...CanonicalOverridesLegacy`, `...CanonicalMaxZeroDefaults`, `...CanonicalExplicitZeroTimeout`을 추가하고 위 presence/effective 표의 각 행을 subtest로 고정한다.
- canonical negative boundary(음수 max_queue, 음수 queue_timeout_ms)는 `resolveProviderPoolPolicy`의 validation에서 이미 처리하므로 별도 test file이 아닌 `provider_catalog_config_test.go`의 canonical negative subtest로 포함한다.
- fixture는 provider 순서를 뒤집은 conflict subtest와 `queue_timeout_ms: 0`을 포함한다.

#### 중간 검증

```bash
gofmt -d packages/go/config/edge_types.go packages/go/config/load.go packages/go/config/provider_types.go packages/go/config/provider_catalog_config_test.go
GOCACHE=/tmp/iop-go-cache-provider-policy-config go test -count=1 ./packages/go/config -run 'TestLoadEdge_ProviderPoolPolicy'
```

기대 결과: gofmt diff 없음, canonical/default/promotion/conflict/order/zero-timeout tests PASS.

### [PROVIDER_POLICY_CONFIG-2] Effective refresh diff와 설정 가이드

#### 문제

`apps/edge/internal/configrefresh/classify.go:81-114,315-338`은 provider별 queue field를 provider live property로 비교한다. `configs/edge.yaml:134-135,320-350`과 `apps/edge/README.md:254-305`도 provider마다 서로 다른 queue policy를 권장해 SDD owner와 충돌한다.

#### 해결 방법

`appendEdgeChanges`에서 effective root `provider_pool.max_queue`와 `provider_pool.queue_timeout_ms`를 `StatusApplied`로 비교한다. provider diff index에서는 legacy queue fields를 제거해 canonical config가 존재할 때 legacy 편집이 runtime policy change로 보고되지 않게 한다. canonical 부재 legacy 변경은 LoadEdge 승격 결과가 root effective diff로 나타난다.

Before (`classify.go:321-329`):

```go
appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].priority", provID), StatusApplied, cur.Priority, next.Priority)
appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].max_queue", provID), StatusApplied, cur.MaxQueue, next.MaxQueue)
appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].queue_timeout_ms", provID), StatusApplied, cur.QueueTimeoutMS, next.QueueTimeoutMS)
```

After:

```go
appendIfChanged(changes, "provider_pool.max_queue", StatusApplied, current.ProviderPool.MaxQueue, candidate.ProviderPool.MaxQueue)
appendIfChanged(changes, "provider_pool.queue_timeout_ms", StatusApplied, current.ProviderPool.QueueTimeoutMS, candidate.ProviderPool.QueueTimeoutMS)
```

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

- [x] `apps/edge/internal/configrefresh/classify.go`: root effective policy live diff를 추가하고 provider index의 legacy policy diff를 제거한다.
- [x] `apps/edge/internal/configrefresh/provider_classify_test.go`: root max/timeout applied와 canonical-present legacy-only no-effective-change를 검증한다.
- [x] `configs/edge.yaml`: root canonical block을 추가하고 provider examples의 queue fields를 legacy note로 이동한다.
- [x] `apps/edge/README.md`: provider pool 예시와 migration 설명을 root policy owner로 갱신한다.

#### 테스트 작성

- `TestClassifyProviderPoolPolicyLiveApply`: max/timeout 각각 정확한 root path와 `StatusApplied`.
- `TestClassifyProviderPoolPolicyIgnoresLegacyWhenCanonical`: canonical이 같은 상태에서 provider legacy 값만 달라도 policy diff가 없음.
- 문서는 별도 snapshot test를 만들지 않고 tracked diff와 `edge config check`로 검증한다.

#### 중간 검증

```bash
gofmt -d apps/edge/internal/configrefresh/classify.go apps/edge/internal/configrefresh/provider_classify_test.go
GOCACHE=/tmp/iop-go-cache-provider-policy-config go test -count=1 ./apps/edge/internal/configrefresh -run 'TestClassifyProviderPoolPolicy'
GOCACHE=/tmp/iop-go-cache-provider-policy-config go run ./apps/edge/cmd/edge --config configs/edge.yaml config check
```

기대 결과: root policy changes만 applied로 보고되고 canonical config check가 `OK configs/edge.yaml`로 끝난다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `packages/go/config/edge_types.go` | PROVIDER_POLICY_CONFIG-1 |
| `packages/go/config/load.go` | PROVIDER_POLICY_CONFIG-1 |
| `packages/go/config/provider_types.go` | PROVIDER_POLICY_CONFIG-1 |
| `packages/go/config/provider_catalog_config_test.go` | PROVIDER_POLICY_CONFIG-1 (9 test functions + canonical negative subtests) |
| `apps/edge/internal/configrefresh/classify.go` | PROVIDER_POLICY_CONFIG-2 |
| `apps/edge/internal/configrefresh/provider_classify_test.go` | PROVIDER_POLICY_CONFIG-2 |
| `configs/edge.yaml` | PROVIDER_POLICY_CONFIG-2 |
| `apps/edge/README.md` | PROVIDER_POLICY_CONFIG-2 |

## 최종 검증

```bash
gofmt -d packages/go/config/edge_types.go packages/go/config/load.go packages/go/config/provider_types.go packages/go/config/provider_catalog_config_test.go apps/edge/internal/configrefresh/classify.go apps/edge/internal/configrefresh/provider_classify_test.go
git diff --check
GOCACHE=/tmp/iop-go-cache-provider-policy-config go test -count=1 ./packages/go/config -run 'TestLoadEdge_ProviderPoolPolicy'
GOCACHE=/tmp/iop-go-cache-provider-policy-config go test -count=1 ./apps/edge/internal/configrefresh -run 'TestClassifyProviderPoolPolicy'
GOCACHE=/tmp/iop-go-cache-provider-policy-config go test -count=1 ./packages/go/...
GOCACHE=/tmp/iop-go-cache-provider-policy-config go test -count=1 ./apps/edge/internal/configrefresh
GOCACHE=/tmp/iop-go-cache-provider-policy-config go run ./apps/edge/cmd/edge --config configs/edge.yaml config check
```

기대 결과: format/diff/config/refresh/package 회귀 PASS, config check `OK`. fresh execution만 허용한다.

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