<!-- task=m-node-provider-first-config-surface/07_priority_routing plan=0 tag=API -->

# Plan - API

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

구현 완료 전에는 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 설계 결정, 검증 출력으로 채운다. 구현자는 아래 검증을 실행하고 active plan/review 파일은 그대로 둔 뒤 review 준비를 보고한다. 종결 처리, log rename, `complete.log` 작성, archive 이동은 code-review-skill 전용이다.

선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막는 경우에만 review stub의 `사용자 리뷰 요청` 섹션에 연결 대상, 근거, 실행한 명령, 재개 조건을 기록하고 멈춘다. 구현 중 직접 사용자에게 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하거나 `USER_REVIEW.md`, archive log, `complete.log`를 만들지 않는다. 환경/secret/service blocker, 일반 범위 조정, follow-up으로 닫을 수 있는 evidence 공백은 사용자 리뷰 요청이 아니다.

## 배경

현재 Milestone의 `priority-routing` Task는 provider-pool dispatch 정책을 `in_flight` 우선, 같은 `in_flight` 안에서 `priority` 오름차순 tie-break, 완전 동률이면 기존 rotation 유지로 고정한다. 현재 config schema에는 `nodes[].providers[].priority`가 없고, queue selection은 `in_flight/capacity` 비율을 기준으로 한다. SDD S08은 config contract, validation, model queue tests, refresh classification evidence를 요구한다.

## 사용자 리뷰 요청 흐름

구현 중 사용자 리뷰 요청은 선택된 Milestone lock decision이 실구현을 차단할 때만 active `CODE_REVIEW-*-G??.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 이 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식을 따른다. 직접 사용자 prompt는 금지이며, code-review가 요청의 타당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/node-provider-first-config-surface.md`
- Task ids:
  - `priority-routing`: Provider-pool dispatch가 dispatch 가능한 후보 중 가장 낮은 `in_flight` provider를 우선하고, `in_flight`가 같은 후보에서만 낮은 숫자의 `priority`를 선택 기준으로 사용한다.
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `agent-roadmap/current.md`
- `agent-roadmap/phase/operational-observability-provider-management/PHASE.md`
- `agent-roadmap/phase/operational-observability-provider-management/milestones/node-provider-first-config-surface.md`
- `agent-roadmap/sdd/operational-observability-provider-management/node-provider-first-config-surface/SDD.md`
- `agent-contract/index.md`
- `agent-contract/inner/edge-config-runtime-refresh.md`
- `agent-contract/inner/edge-node-runtime-wire.md`
- `agent-ops/rules/project/domain/edge/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/platform-common-smoke.md`
- `packages/go/config/config.go`
- `packages/go/config/config_test.go`
- `apps/edge/internal/service/model_queue.go`
- `apps/edge/internal/service/model_queue_test.go`
- `apps/edge/internal/service/run_dispatch.go`
- `apps/edge/internal/configrefresh/classify.go`
- `apps/edge/internal/configrefresh/classify_test.go`
- `configs/edge.yaml`
- `.gitignore`

### SDD 기준

- SDD: `agent-roadmap/sdd/operational-observability-provider-management/node-provider-first-config-surface/SDD.md`
- 상태: `[승인됨]`, SDD 잠금: `해제`, SDD 사용자 리뷰: 없음.
- Target Acceptance Scenario: S08, Milestone Task `priority-routing`.
- Evidence Map row: S08 requires config contract update, config validation, model queue dispatch order tests, config refresh classification tests.
- Checklist/final verification은 S08 row에서 역산했다: `priority` schema/validation, `in_flight` count 우선 queue selection, equal `in_flight` priority tie-break, equal priority rotation, priority refresh `applied` classification을 각각 구현/검증한다.

### 테스트 환경 규칙

- test_env: `local`.
- 읽은 env/profile: `agent-test/local/rules.md`, `agent-test/local/edge-smoke.md`, `agent-test/local/platform-common-smoke.md`.
- 적용 명령: local Go quick check. 구현 후 fresh execution이 필요하므로 Go test cache output은 최종 PASS evidence로 인정하지 않는다.
- 검증 명령 layout 확인:
  - `go test ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/configrefresh`: PASS, cached.
  - `go test -count=1 ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/configrefresh`: PASS.
  - `go test -count=1 ./apps/edge/... ./packages/go/...`: PASS.
- 테스트 환경 프리플라이트: required verification은 현재 checkout local Go tests 안에서 끝난다. 원격 runner, Docker, field/bootstrap, 외부 provider, shared runtime은 사용하지 않는다.
- fallback source: agent-test profiles가 적용 가능하므로 별도 fallback 불필요.

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

- `nodes[].providers[].priority` unmarshal/default/negative validation: 현재 없음. `packages/go/config/config_test.go`에 추가한다.
- provider-pool selection: 현재 `apps/edge/internal/service/model_queue_test.go:499-580`이 `in_flight/capacity` 비율과 비례 filling을 검증한다. S08 정책과 충돌하므로 `in_flight` count 우선과 priority tie-break 테스트로 교체/추가한다.
- config refresh classification: capacity/max_queue/queue_timeout/enabled live apply는 있지만 priority-only diff는 없음. `apps/edge/internal/configrefresh/classify_test.go`에 추가한다.
- 외부 full-cycle smoke: S08 Evidence Map은 deterministic config/queue/classification tests를 요구한다. 현재 public API는 request별 최종 provider id를 노출하지 않으므로 per-request priority selection은 unit tests로 검증한다.

### 심볼 참조

- 새 필드 추가: `NodeProviderConf.Priority`, `candidateNode.priority`.
- 제거/교체 후보: `loadRatioLess`, `loadRatioEqual`. `rg --sort path` 결과 call site는 `apps/edge/internal/service/model_queue.go:202`, `apps/edge/internal/service/model_queue.go:206`, 함수 정의 `:228`, `:232`뿐이다.
- 테스트 이름 교체 후보: `TestModelQueueProviderLoadRatioSelection`, `TestModelQueueProviderLoadRatioFillsProportionally`. `rg --sort path` 결과 해당 정의는 `apps/edge/internal/service/model_queue_test.go:501`, `:538`뿐이다.

### 분할 판단

- Milestone-linked task group: `agent-task/m-node-provider-first-config-surface/`.
- SDD Evidence Map이 이 work item을 `07_priority_routing`로 지정하므로 active task path는 `agent-task/m-node-provider-first-config-surface/07_priority_routing/`다.
- `07_priority_routing`는 `+` dependency가 없는 독립 subtask다. predecessor `complete.log` 확인은 필요 없다.
- split policy는 평가했다. config schema, service dispatch, refresh classification은 서로 다른 domain을 건드리지만 S08의 단일 runtime behavior를 이루며, 부분 완료 시 contract와 runtime이 불일치한다. 추가 분할은 incomplete evidence를 만들기 쉬워 이 subtask 안의 단일 plan으로 묶는다.

### 범위 결정 근거

- 포함: `packages/go/config`, `apps/edge/internal/service`, `apps/edge/internal/configrefresh`, `configs/edge.yaml`, `agent-contract/inner/edge-config-runtime-refresh.md`.
- 제외: `proto/iop/runtime.proto`, `proto/gen/**`, Node adapter runtime, Control Plane/Client, OpenAI response schema. `priority`는 Edge config/dispatch policy이며 Node wire payload 변경이 필요하지 않다.
- 제외: dev-runtime/field smoke 변경. 기존 `full-cycle-smoke` Task는 완료되어 있고, S08은 dispatch order를 deterministic tests로 검증한다.

### 빌드 등급

- `local-G06`: 변경은 config contract와 Edge dispatch에 걸치지만 범위가 작고, SDD가 정책을 확정했으며 local Go tests로 결정적으로 검증 가능하다.

## 구현 체크리스트

- [x] `NodeProviderConf.Priority`를 config schema, contract, `configs/edge.yaml` 예시에 추가하고 기본값 0/음수 거부를 `packages/go/config` tests로 검증한다.
- [x] provider-pool queue candidate에 priority를 전달하고 선택 순서를 `in_flight` 오름차순 -> `priority` 오름차순 -> 기존 rotation으로 바꾸며 service/model queue tests를 갱신한다.
- [x] config refresh classification에서 provider priority-only 변경을 live `applied`로 분류하고 classification tests를 추가한다.
- [x] `go test -count=1 ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/configrefresh`와 `go test -count=1 ./apps/edge/... ./packages/go/...`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [API-1] Config Schema And Contract

문제:

- `packages/go/config/config.go:146-153`에는 capacity/queue fields만 있고 `priority`가 없다.
- `packages/go/config/config.go:192-199`는 capacity/max_queue/queue_timeout만 non-negative로 검증한다.
- `configs/edge.yaml:96-102`와 `agent-contract/inner/edge-config-runtime-refresh.md:42-45`도 priority contract와 live apply classification을 설명하지 않는다.

Before:

```go
// packages/go/config/config.go:146-153
// Capacity is the provider/resource maximum concurrent execution slots.
// Provider-pool dispatch treats 0 as not dispatchable rather than falling
// back to node runtime concurrency metadata.
Capacity int `mapstructure:"capacity" yaml:"capacity,omitempty"`
// MaxQueue is the maximum queue depth.
MaxQueue int `mapstructure:"max_queue" yaml:"max_queue,omitempty"`
// QueueTimeoutMS is the queue timeout in milliseconds.
QueueTimeoutMS int `mapstructure:"queue_timeout_ms" yaml:"queue_timeout_ms,omitempty"`
```

```go
// packages/go/config/config.go:192-199
if p.Capacity < 0 {
	return fmt.Errorf("nodes[].providers[%q].capacity must be non-negative", id)
}
if p.MaxQueue < 0 {
	return fmt.Errorf("nodes[].providers[%q].max_queue must be non-negative", id)
}
if p.QueueTimeoutMS < 0 {
	return fmt.Errorf("nodes[].providers[%q].queue_timeout_ms must be non-negative", id)
}
```

해결 방법:

- `NodeProviderConf`에 `Priority int `mapstructure:"priority" yaml:"priority,omitempty"`를 추가한다.
- `Validate`에서 `Priority < 0`을 `nodes[].providers[%q].priority must be non-negative`로 reject한다. zero value가 기본값 0이다.
- `configs/edge.yaml` provider-pool comments와 sample providers에 priority 설명/예시를 추가한다.
- `agent-contract/inner/edge-config-runtime-refresh.md` 핵심 규칙과 refresh 분류 기준에 priority를 추가한다.

After:

```go
// packages/go/config/config.go
Priority int `mapstructure:"priority" yaml:"priority,omitempty"`

if p.Priority < 0 {
	return fmt.Errorf("nodes[].providers[%q].priority must be non-negative", id)
}
```

수정 파일 및 체크리스트:

- [x] `packages/go/config/config.go`: `NodeProviderConf.Priority`와 validation 추가.
- [x] `packages/go/config/config_test.go`: provider-first happy path에서 priority unmarshal을 확인하고, omitted priority 기본값 0과 negative rejection test를 추가한다.
- [x] `configs/edge.yaml`: provider-pool comment와 example에 `priority` 의미를 추가한다.
- [x] `agent-contract/inner/edge-config-runtime-refresh.md`: priority rule과 live apply classification을 추가한다.

테스트 작성:

- 작성: `packages/go/config/config_test.go`
- 대상: `TestLoadEdge_ProviderFirstNodeProvidersHappyPath`, `TestLoadEdge_NodeProviderConfNegativePriorityRejected` 또는 `TestNodeProviderConf_Validate` subcase.
- assertion: priority omitted -> 0, explicit priority loads, negative priority rejects.

중간 검증:

```bash
go test -count=1 ./packages/go/config
```

Expected: PASS.

### [API-2] Provider Queue Priority Selection

문제:

- `apps/edge/internal/service/model_queue.go:28-39`의 `candidateNode`는 priority를 보존하지 않는다.
- `apps/edge/internal/service/model_queue.go:182-207`은 가장 낮은 `in_flight/capacity` ratio를 고른다. SDD S08은 capacity ratio가 아니라 dispatch 가능한 후보 중 가장 낮은 absolute `in_flight`를 먼저 고르라고 정한다.
- `apps/edge/internal/service/run_dispatch.go:610-616`은 provider config에서 candidate를 만들 때 priority를 전달하지 않는다.
- `apps/edge/internal/service/model_queue_test.go:499-580`은 기존 ratio/proportional behavior를 고정한다.

Before:

```go
// apps/edge/internal/service/model_queue.go:182-207
// findAvailableNodeLocked returns the available candidate with the lowest
// in_flight/capacity load ratio.
...
case len(best) == 0 || loadRatioLess(inflight, c.capacity, bestInflight, bestCapacity):
	best = []candidateNode{c}
	bestInflight = inflight
	bestCapacity = c.capacity
case loadRatioEqual(inflight, c.capacity, bestInflight, bestCapacity):
	best = append(best, c)
```

```go
// apps/edge/internal/service/run_dispatch.go:610-616
candidates = append(candidates, candidateNode{
	entry:        entry,
	capacity:     cap,
	providerID:   prov.ID,
	adapter:      adapterKey,
	servedTarget: servedModel,
})
```

해결 방법:

- `candidateNode`에 `priority int`를 추가한다.
- `resolveProviderPoolCandidates`에서 `priority: prov.Priority`를 설정한다. legacy non-provider candidates는 zero value 0을 유지한다.
- `findAvailableNodeLocked`를 absolute `inflight` 우선 비교로 바꾼다. 같은 `inflight` 안에서 낮은 `priority`를 고르고, `inflight`와 `priority`가 모두 같은 후보만 `best` slice에 넣어 기존 rotation을 유지한다.
- `loadRatioLess`/`loadRatioEqual` helper는 더 이상 쓰지 않으면 제거한다.
- model queue tests를 S08 중심으로 갱신한다.

After:

```go
// apps/edge/internal/service/model_queue.go
type candidateNode struct {
	entry        *edgenode.NodeEntry
	capacity     int
	priority     int
	providerID   string
	adapter      string
	servedTarget string
}

switch {
case len(best) == 0 || inflight < bestInflight:
	best = []candidateNode{c}
	bestInflight = inflight
	bestPriority = c.priority
case inflight == bestInflight:
	if c.priority < bestPriority {
		best = []candidateNode{c}
		bestPriority = c.priority
	} else if c.priority == bestPriority {
		best = append(best, c)
	}
}
```

수정 파일 및 체크리스트:

- [x] `apps/edge/internal/service/model_queue.go`: `candidateNode.priority`, selector comparison, comment 갱신, unused ratio helpers 제거.
- [x] `apps/edge/internal/service/run_dispatch.go`: provider-pool candidate에 `prov.Priority` 전달.
- [x] `apps/edge/internal/service/model_queue_test.go`: ratio/proportional tests를 priority policy tests로 교체하고 existing equal-priority rotation을 보존한다.

테스트 작성:

- 작성: `apps/edge/internal/service/model_queue_test.go`
- tests:
  - `TestModelQueueProviderInflightSelectionBeatsPriority`: priority가 낮아도 `in_flight`가 더 높은 후보는 선택되지 않는다.
  - `TestModelQueueProviderPriorityBreaksEqualInflightTie`: 같은 `in_flight`이면 낮은 priority 후보가 선택된다.
  - `TestModelQueueProviderEqualInflightPriorityRotates`: `in_flight`와 priority가 모두 같으면 기존 rotation이 유지된다.
  - candidate resolution test에서 `Priority`가 `candidateNode.priority`로 전달되는지 확인한다.

중간 검증:

```bash
go test -count=1 ./apps/edge/internal/service
```

Expected: PASS.

### [API-3] Refresh Classification

문제:

- `apps/edge/internal/configrefresh/classify.go:81-111`의 provider diff key에는 priority가 없다.
- `apps/edge/internal/configrefresh/classify.go:316-339`는 capacity/max_queue/queue_timeout만 `StatusApplied`로 분류한다.
- `apps/edge/internal/configrefresh/classify_test.go:94-150`은 capacity applied만 검증하고 priority applied test가 없다.

Before:

```go
// apps/edge/internal/configrefresh/classify.go:81-91
type providerKey struct {
	NodeKey        string
	Type           string
	Category       config.Category
	Adapter        string
	Models         []string
	Health         string
	Capacity       int
	MaxQueue       int
	QueueTimeoutMS int
```

```go
// apps/edge/internal/configrefresh/classify.go:316-339
if cur.Capacity != cp.Capacity { ... StatusApplied ... }
if cur.MaxQueue != cp.MaxQueue { ... StatusApplied ... }
if cur.QueueTimeoutMS != cp.QueueTimeoutMS { ... StatusApplied ... }
```

해결 방법:

- `providerKey`에 `Priority int`를 추가하고 `buildProviderIndex`에서 `p.Priority`를 복사한다.
- priority diff는 `nodes[].providers[%q].priority`, `StatusApplied`로 추가한다.
- contract의 live apply list와 test assertion을 맞춘다.

After:

```go
// apps/edge/internal/configrefresh/classify.go
Priority int

if cur.Priority != cp.Priority {
	changes = append(changes, Change{
		Path:     fmt.Sprintf("nodes[].providers[%q].priority", provID),
		Class:    StatusApplied,
		Previous: fmt.Sprintf("%d", cur.Priority),
		Next:     fmt.Sprintf("%d", cp.Priority),
	})
}
```

수정 파일 및 체크리스트:

- [x] `apps/edge/internal/configrefresh/classify.go`: priority diff를 `StatusApplied`로 분류.
- [x] `apps/edge/internal/configrefresh/classify_test.go`: priority-only diff가 applied이고 restart_required가 없음을 검증.
- [x] `agent-contract/inner/edge-config-runtime-refresh.md`: API-1 contract update와 classification wording 일치 확인.

테스트 작성:

- 작성: `apps/edge/internal/configrefresh/classify_test.go`
- test: `TestClassifyProviderPriorityApplied`
- assertion: path `nodes[].providers["prov-a"].priority`, class `applied`, previous/next value, no `restart_required`.

중간 검증:

```bash
go test -count=1 ./apps/edge/internal/configrefresh
```

Expected: PASS.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `packages/go/config/config.go` | API-1 |
| `packages/go/config/config_test.go` | API-1 |
| `configs/edge.yaml` | API-1 |
| `agent-contract/inner/edge-config-runtime-refresh.md` | API-1, API-3 |
| `apps/edge/internal/service/model_queue.go` | API-2 |
| `apps/edge/internal/service/run_dispatch.go` | API-2 |
| `apps/edge/internal/service/model_queue_test.go` | API-2 |
| `apps/edge/internal/configrefresh/classify.go` | API-3 |
| `apps/edge/internal/configrefresh/classify_test.go` | API-3 |

## 의존 관계 및 구현 순서

1. API-1 schema/contract를 먼저 추가한다.
2. API-2 dispatch candidate와 selection tests를 갱신한다.
3. API-3 refresh classification을 schema 필드에 맞춰 추가한다.
4. 최종 검증 후 review stub을 채운다.

## 최종 검증

```bash
go test -count=1 ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/configrefresh
```

Expected: PASS. Config validation, queue dispatch order, refresh classification targeted evidence.

```bash
go test -count=1 ./apps/edge/... ./packages/go/...
```

Expected: PASS. Edge/platform-common regression. Go test cache output은 최종 PASS evidence로 사용하지 않는다.

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