<!-- task=m-runtime-reconnect-config-refresh/02_refresh_contract plan=0 tag=REFRESH_CONTRACT -->

# Plan - REFRESH_CONTRACT

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

이 계획을 구현한 뒤에는 반드시 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 검증 출력, 계획 대비 변경 사항으로 채운다. 구현이 끝나도 review finalization, 로그 rename, `complete.log` 작성, archive 이동은 code-review 스킬 전용이며 구현 에이전트가 수행하지 않는다.

선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 review stub의 `사용자 리뷰 요청` 섹션을 정확한 연결 근거와 함께 채우고 멈춘다. 구현 중 사용자에게 직접 묻거나, chat 선택지를 제시하거나, `request_user_input`을 호출하거나, `USER_REVIEW.md`를 만들지 않는다. 환경/secret/service 준비, 일반 범위 조정, 검증 증거 공백은 사용자 리뷰 요청이 아니라 검증 결과나 후속 계획으로 기록한다.

## 배경

현재 Edge는 시작 시 `edge.yaml`을 한 번 읽어 runtime을 조립하고, 운영 중 config refresh를 받는 표면이 없다. Milestone의 refresh 에픽은 실행 중 Edge process에 도달하는 명시 entrypoint와 dry-run/apply 모드, 그리고 mutable/immutable/rejected diff 분류를 요구한다. 이 계획은 runtime apply hardening과 Node push를 제외하고, refresh 요청/응답 계약과 classifier의 첫 구현 범위를 고정한다.

## 사용자 리뷰 요청 흐름

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

## Roadmap Targets

- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
  - `entrypoint`: refresh MVP entrypoint가 정리되고 구현된다. CLI command/API/Control Plane command 중 선택한 표면은 실행 중 Edge process에 도달해야 하며 dry-run과 apply 결과를 구분한다.
  - `diff-classify`: config refresh는 기존 runtime config와 새 config의 diff를 산출하고 `applied`, `restart_required`, `rejected`로 변경을 분류한다.
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `AGENTS.md`
- `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-roadmap/current.md`
- `agent-roadmap/phase/update-plane-self-update-foundation/PHASE.md`
- `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- `agent-roadmap/sdd/update-plane-self-update-foundation/runtime-reconnect-config-refresh/SDD.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`
- `agent-test/local/testing-smoke.md`
- `apps/edge/internal/edgecmd/config.go`
- `apps/edge/internal/edgecmd/root.go`
- `apps/edge/internal/edgecmd/edgecmd_test.go`
- `apps/edge/cmd/edge/main.go`
- `apps/edge/internal/bootstrap/module.go`
- `apps/edge/internal/bootstrap/runtime.go`
- `apps/edge/internal/bootstrap/runtime_test.go`
- `apps/edge/internal/input/manager.go`
- `apps/edge/internal/openai/server.go`
- `apps/edge/internal/openai/routes.go`
- `apps/edge/internal/service/service.go`
- `apps/edge/internal/service/run_dispatch.go`
- `apps/edge/internal/service/model_queue.go`
- `apps/edge/internal/service/service_internal_test.go`
- `apps/edge/internal/service/service_test.go`
- `apps/edge/internal/service/model_queue_test.go`
- `apps/edge/internal/node/store.go`
- `apps/edge/internal/node/mapper.go`
- `apps/edge/internal/node/registry.go`
- `packages/go/config/config.go`
- `packages/go/config/config_test.go`
- `proto/iop/runtime.proto`
- `configs/edge.yaml`

### SDD 기준

- SDD: `agent-roadmap/sdd/update-plane-self-update-foundation/runtime-reconnect-config-refresh/SDD.md`, 상태 `[승인됨]`, SDD 잠금 `해제`.
- 대상 Acceptance Scenario:
  - S06 -> `entrypoint`: running Edge process가 dry-run/apply refresh 요청을 받고 mode별 결과를 반환해야 한다.
  - S07 -> `diff-classify`: provider capacity 변경은 `applied`로 분류된다.
  - S08 -> `diff-classify`: listen port 변경은 `restart_required`로 분류되고 live apply되지 않는다.
  - S09 -> `diff-classify`: invalid YAML 또는 invalid provider config는 `rejected`이고 기존 상태가 유지된다.
- Evidence Map:
  - S06: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract`, refresh entrypoint unit/integration test.
  - S07: same task, provider capacity classifier test.
  - S08: same task, listen port classifier test.
  - S09: same task, invalid config refresh test.
- 반영 방식: 구현 checklist는 Edge-local admin API와 CLI가 running process에 도달하는지, dry-run/apply 모드가 응답에 분리되는지, classifier가 S07-S09 세 케이스를 deterministic test로 증명하는지에서 역산한다.

### 테스트 환경 규칙

- `test_env=local`.
- `agent-test/local/rules.md`가 존재해 전체를 읽었다.
- 매칭 profile:
  - `agent-test/local/edge-smoke.md`: Edge 단위 테스트 `go test ./apps/edge/...`, smoke `./scripts/e2e-smoke.sh`, e2e `make test-e2e`.
  - `agent-test/local/platform-common-smoke.md`: config/proto 공통 확인 `go test ./packages/go/... ./proto/gen/...`, proto 변경 시 `make proto`.
  - `agent-test/local/testing-smoke.md`: 검증 규칙 변경 시 테스트 규칙 자체 확인. 이번 계획에서는 테스트 규칙 파일을 수정하지 않는다.
- 적용 명령은 Go cache를 허용하지 않도록 `-count=1`을 사용한다. 새 외부 dependency는 추가하지 않으므로 manifest fallback은 필요 없다.

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

- Running Edge refresh entrypoint: 기존 테스트 없음. `apps/edge/internal/bootstrap` 또는 `apps/edge/cmd/edge` 레벨에서 local admin API에 dry-run/apply 요청이 도달하는 통합 테스트가 필요하다.
- CLI refresh command: 기존 `edgecmd` 테스트는 config/node command 중심이며 refresh command가 없다. CLI flag, mode, HTTP request/response 출력 테스트가 필요하다.
- Diff classifier: provider capacity, listen port, invalid YAML/provider config classifier 테스트가 없다. 새 package 단위 테스트가 필요하다.
- 기존 config validation: `packages/go/config/config_test.go`는 provider/model validation을 넓게 커버하지만 runtime current-vs-candidate diff는 커버하지 않는다.

### 심볼 참조

- renamed/removed symbol 없음.
- 새 public-ish internal API 후보: `configrefresh.Classify`, `configrefresh.Result`, `Runtime.Refresh`, `edgecmd.RefreshCmd`는 신규 추가라 기존 call site 없음.

### 분할 판단

- split decision policy를 plan 파일 선택 전에 평가했다.
- 공유 task group: `agent-task/m-runtime-reconnect-config-refresh`.
- sibling plan:
  - `02_refresh_contract`: entrypoint와 diff classifier. 선행 의존성 없음.
  - `03+02_edge_refresh_apply`: atomic apply와 ops report. `02_refresh_contract`의 `complete.log`가 필요하다.
- API/contract와 runtime apply hardening은 위험도와 검증 전략이 달라 split한다. 이 plan은 S06-S09만 check-on-pass하고, S10-S11은 dependent plan에서 처리한다.

### 범위 결정 근거

- 이번 plan은 Node config push/ack `push-contract`를 제외한다. SDD Evidence Map에는 S12가 같은 `02_refresh_contract` 경로로 적혀 있지만, 사용자의 실행 범위는 refresh 에픽의 네 작업이며 S12는 node-config 에픽이다.
- Node runtime, adapter registry, generated proto 변경은 제외한다.
- 자동 file watcher hot reload와 frontend refresh UI는 Milestone 범위 제외에 따라 제외한다.
- 실제 apply 실패 rollback과 ops report completeness는 `03+02_edge_refresh_apply`에서 다룬다. 이 plan의 apply mode는 running process 도달, mode 구분, classify 결과 반환, mutable-only happy path hook까지로 제한한다.

### 빌드 등급

- `cloud-G07`: running process entrypoint, CLI/API stdout/HTTP contract, process lifecycle test가 중심이라 local allowlist를 넘는다. 범위는 Edge 내부로 제한되므로 G08 이상은 dependent apply hardening plan에 남긴다.

## 구현 체크리스트

- [ ] [REFRESH_CONTRACT-1] Edge-local refresh admin API와 `iop-edge config refresh` CLI를 추가해 running Edge process에 dry-run/apply 요청이 도달하게 한다.
- [ ] [REFRESH_CONTRACT-2] candidate config load/validation/diff classifier를 추가하고 provider capacity는 `applied`, listen port는 `restart_required`, invalid YAML/provider config는 `rejected`로 테스트한다.
- [ ] [REFRESH_CONTRACT-3] `entrypoint`와 `diff-classify` 대상 최종 검증 명령을 실행하고 실제 stdout/stderr를 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## REFRESH_CONTRACT-1 - Running Process Entrypoint

### 문제

`config check`는 `LoadEdgeConfig`로 파일을 읽어 검증만 하고 종료한다. 실행 중 Edge process와 연결되는 refresh 표면은 없다.

Before:

```go
// apps/edge/internal/edgecmd/config.go:110
func configCheckCmd() *cobra.Command {
	return &cobra.Command{
		Use:   "check",
		Short: "Validate edge config can be loaded",
		RunE: func(cmd *cobra.Command, _ []string) error {
			cfg, path, err := LoadEdgeConfig(cmd)
```

`serve`도 시작 시 config를 한 번 읽고 `bootstrap.Module(cfg)`를 실행한다.

```go
// apps/edge/cmd/edge/main.go:38
RunE: func(cmd *cobra.Command, _ []string) error {
	cfg, err := loadRuntimeConfig(cmd)
	if err != nil {
		return err
	}
	fx.New(bootstrap.Module(cfg)).Run()
	return nil
},
```

### 해결 방법

- MVP 표면은 "Edge-local admin API + CLI wrapper"로 고정한다.
- `packages/go/config.EdgeConfig`에 `Refresh EdgeRefreshConf`를 추가한다. 기본은 disabled, listen 기본값은 loopback으로 둔다. 테스트는 `refresh.enabled=true`, `refresh.listen=127.0.0.1:0`을 사용한다.
- `bootstrap.Runtime`에 refresh admin server를 추가하고, enabled일 때 `POST /refresh`를 받는다. request body는 `{mode, config_path, request_id}`이고 mode는 `dry_run|apply`만 허용한다.
- `edgecmd config refresh`는 `--mode dry-run|apply`, `--addr`, `--config`를 받아 running Edge의 admin API로 요청하고 JSON 결과를 출력한다. `--mode` 기본은 `dry-run`으로 둔다.
- `apply` 모드는 이 plan에서 classifier 결과와 mutable-only happy path를 반환하되, apply failure rollback 증명은 REFRESH_APPLY plan으로 넘긴다.

After shape:

```go
// apps/edge/internal/bootstrap/runtime.go
type Runtime struct {
	Cfg *config.EdgeConfig
	Refresh *RefreshAdminServer
}

func (r *Runtime) RefreshConfig(ctx context.Context, req configrefresh.Request) (configrefresh.Result, error) {
	return configrefresh.Evaluate(ctx, r.Cfg, req)
}
```

```go
// apps/edge/internal/edgecmd/config.go
c.AddCommand(configInitCmd(), configPrintCmd(), configCheckCmd(), configRefreshCmd())
```

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

- [ ] `packages/go/config/config.go`: `EdgeRefreshConf`와 `EdgeConfig.Refresh` 추가, defaults 추가.
- [ ] `packages/go/config/config_test.go`: refresh defaults/unmarshal test 추가.
- [ ] `apps/edge/internal/bootstrap/runtime.go`: refresh admin server lifecycle wiring 추가.
- [ ] `apps/edge/internal/bootstrap/refresh_admin.go`: local HTTP handler 추가.
- [ ] `apps/edge/internal/bootstrap/runtime_test.go`: running runtime에 dry-run/apply 요청 도달 테스트 추가.
- [ ] `apps/edge/internal/edgecmd/config.go`: `config refresh` command 추가.
- [ ] `apps/edge/internal/edgecmd/edgecmd_test.go`: CLI request/JSON output/invalid mode test 추가.
- [ ] `apps/edge/cmd/edge/main.go`: running config path를 Runtime refresh 기본값으로 전달할 수 있게 보존한다.

### 테스트 작성

- 작성: `apps/edge/internal/bootstrap/runtime_test.go`에 `TestRefreshAdminDryRunAndApplyReachRunningRuntime`.
- 작성: `apps/edge/internal/edgecmd/edgecmd_test.go`에 `TestConfigRefreshCommandPostsModeAndConfigPath`, `TestConfigRefreshCommandRejectsInvalidMode`.
- 작성: `packages/go/config/config_test.go`에 `TestEdgeRefreshDefaults`.

### 중간 검증

```bash
go test -count=1 ./apps/edge/internal/edgecmd ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./packages/go/config
```

예상: refresh config schema, admin API lifecycle, CLI wrapper 테스트가 모두 PASS.

## REFRESH_CONTRACT-2 - Diff Classifier

### 문제

현재 runtime 조립은 config 값을 서비스에 직접 주입하고 끝난다. current config와 candidate config의 차이를 `applied`, `restart_required`, `rejected`로 분류하는 경계가 없다.

Before:

```go
// apps/edge/internal/bootstrap/runtime.go:48
registry := edgenode.NewRegistry()
nodeStore, err := edgenode.LoadFromConfig(cfg.Nodes)
if err != nil {
	return nil, fmt.Errorf("edge: seed node store: %w", err)
}
svc := edgeservice.New(registry, bus)
svc.SetNodeStore(nodeStore)
svc.SetModelCatalog(cfg.Models)
```

Provider dispatch는 현재 `s.modelCatalog`와 `s.nodeStore`를 읽지만 refresh diff source가 없다.

```go
// apps/edge/internal/service/run_dispatch.go:491
func (s *Service) resolveProviderPoolCandidates(req SubmitRunRequest) ([]candidateNode, groupPolicy, error) {
	var catalogEntry *config.ModelCatalogEntry
	for i := range s.modelCatalog {
```

### 해결 방법

- 새 package `apps/edge/internal/configrefresh`를 추가한다.
- `LoadCandidate(path)`는 `config.LoadEdge`와 shared runtime validation을 수행한다. 기존 `edgecmd.validateEdgeConfig` 로직은 CLI 전용 private 함수에서 공유 가능한 validator로 이동하거나 wrapper를 둔다.
- `Classify(current, candidate)`는 stable key 기준으로 changed node/provider/model paths를 만든다.
- Mutable `applied`:
  - `nodes[].providers[].capacity`
  - `nodes[].providers[].max_queue`
  - `nodes[].providers[].queue_timeout_ms`
  - `models[].providers`가 기존 provider id와 served model membership validation을 통과하는 변경
- `restart_required`:
  - `server.listen`
  - `bootstrap.listen`
  - `bootstrap.artifact_dir`
  - `openai.listen`
  - `a2a.listen`
  - `metrics.port`
  - `edge.id`, `edge.name`
- `rejected`:
  - YAML parse/load error
  - provider validation error
  - duplicate provider/model id
  - unsupported mixed mutable and immutable apply request if implementation cannot safely apply partial changes
- Result는 `status`, `mode`, `request_id`, `changes[]`, `summary`를 포함한다.

After shape:

```go
// apps/edge/internal/configrefresh/result.go
type Status string
const (
	StatusApplied Status = "applied"
	StatusRestartRequired Status = "restart_required"
	StatusRejected Status = "rejected"
)

type Change struct {
	Path string `json:"path"`
	Class Status `json:"class"`
	Previous string `json:"previous,omitempty"`
	Next string `json:"next,omitempty"`
}
```

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

- [ ] `apps/edge/internal/configrefresh/*.go`: request/result types, candidate loading, validation wrapper, classifier 구현.
- [ ] `apps/edge/internal/configrefresh/*_test.go`: S07-S09 classifier tests 추가.
- [ ] `apps/edge/internal/edgecmd/config.go`: `config check`와 refresh가 같은 validator를 쓰게 정리.
- [ ] `apps/edge/internal/bootstrap/refresh_admin.go`: rejected/restart_required/applied 결과를 HTTP status와 JSON body로 반환.

### 테스트 작성

- 작성: `TestClassifyProviderCapacityApplied`.
- 작성: `TestClassifyListenPortRestartRequired`.
- 작성: `TestRefreshInvalidYAMLRejected`.
- 작성: `TestRefreshInvalidProviderRejected`.
- fixture는 `t.TempDir()`에 current/candidate YAML을 쓰고 `config.LoadEdge`와 classifier를 통과시킨다.

### 중간 검증

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

예상: 새 classifier package를 포함한 Edge와 shared config 테스트가 모두 PASS.

## REFRESH_CONTRACT-3 - Final Evidence

### 문제

Milestone Task의 검증은 SDD Evidence Map의 S06-S09를 complete.log에서 복사 가능한 형태로 남겨야 한다. 구현만 하고 review stub을 비워두면 loop가 완료 증거를 만들 수 없다.

### 해결 방법

- 최종 검증 명령을 모두 실행한다.
- `CODE_REVIEW-cloud-G07.md`의 구현 항목별 완료 여부, 구현 체크리스트, 계획 대비 변경 사항, 주요 설계 결정, 검증 결과를 실제 내용으로 채운다.
- 출력이 길면 command와 저장 경로를 함께 기록한다.

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

- [ ] `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/CODE_REVIEW-cloud-G07.md`: 구현 에이전트 소유 섹션 작성.

### 테스트 작성

- 별도 테스트 없음. 이 항목은 evidence 기록 단계다.

### 중간 검증

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

예상: Edge, shared config, generated proto package가 모두 PASS.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `packages/go/config/config.go` | REFRESH_CONTRACT-1 |
| `packages/go/config/config_test.go` | REFRESH_CONTRACT-1, REFRESH_CONTRACT-2 |
| `apps/edge/internal/configrefresh/*.go` | REFRESH_CONTRACT-2 |
| `apps/edge/internal/configrefresh/*_test.go` | REFRESH_CONTRACT-2 |
| `apps/edge/internal/bootstrap/runtime.go` | REFRESH_CONTRACT-1 |
| `apps/edge/internal/bootstrap/refresh_admin.go` | REFRESH_CONTRACT-1, REFRESH_CONTRACT-2 |
| `apps/edge/internal/bootstrap/runtime_test.go` | REFRESH_CONTRACT-1 |
| `apps/edge/internal/edgecmd/config.go` | REFRESH_CONTRACT-1, REFRESH_CONTRACT-2 |
| `apps/edge/internal/edgecmd/edgecmd_test.go` | REFRESH_CONTRACT-1 |
| `apps/edge/cmd/edge/main.go` | REFRESH_CONTRACT-1 |
| `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/CODE_REVIEW-cloud-G07.md` | REFRESH_CONTRACT-3 |

## 최종 검증

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

예상: 모든 Edge/config/proto generated package 테스트 PASS.

```bash
go test -count=1 ./apps/edge/internal/edgecmd ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./packages/go/config
```

예상: CLI entrypoint, running admin API, refresh config schema targeted tests PASS.

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