<!-- task=m-runtime-reconnect-config-refresh/04+02_config_push_contract plan=0 tag=NCPUSH -->

# Plan - NCPUSH

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

`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증을 실행하고, 실제 구현 내용과 실제 stdout/stderr를 리뷰 stub에 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 archive, `complete.log`, roadmap 완료 처리는 code-review/runtime 책임이다. 선택된 Milestone의 `구현 잠금 > 결정 필요` 항목 때문에 구현이 막힐 때만 리뷰 stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 적고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하거나, `USER_REVIEW.md`/`complete.log`를 직접 만들지 않는다. 환경/secret/service 차단, 일반 범위 조정, 후속 에이전트가 닫을 수 있는 evidence 공백은 사용자 리뷰 요청이 아니다.

## 배경

현재 `NodeConfigPayload`는 최초 registration 응답에만 실리고, Edge refresh apply가 연결 Node에 새 payload를 보내는 request/ack 계약이 없다. Milestone의 `push-contract`와 SDD S12는 Edge가 refresh payload를 보내고 Node가 ack/failure를 protocol response로 돌려주는 경로를 요구한다. 이 계획은 wire contract와 Edge/Node transport plumbing까지만 완료하고, 실제 adapter diff/live apply는 후속 split plan에서 처리한다.

## 사용자 리뷰 요청 흐름

구현 중 사용자 리뷰 요청은 선택된 Milestone lock decision이 실제 구현을 막을 때만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 해당 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 템플릿을 따른다. 구현 에이전트는 직접 사용자에게 질문하지 않으며, code-review가 요청 타당성을 검증하고 필요할 때만 `USER_REVIEW.md`를 작성한다.

## Archive Evidence Snapshot

- `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/complete.log`: PASS. `entrypoint`, `diff-classify`만 Roadmap Completion으로 확인되었고, `push-contract`는 계획에서 제외되어 완료 근거가 없다.
- `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply/complete.log`: PASS. `atomic-apply`, `ops-report`만 완료되었고, connected Node refresh push/ack는 제외되었다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
  - `push-contract`: Edge-to-Node config refresh message 또는 command 계약이 proto/config 구조에 추가되고 backward compatibility 기준이 정리된다. 검증: generated proto와 handler 테스트가 통과한다.
- 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/node/rules.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/node-smoke.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/platform-common-smoke.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`
- `proto/iop/runtime.proto`
- `apps/edge/internal/transport/server.go`
- `apps/edge/internal/bootstrap/runtime.go`
- `apps/edge/internal/configrefresh/result.go`
- `apps/edge/internal/configrefresh/classify.go`
- `apps/edge/internal/node/registry.go`
- `apps/edge/internal/node/mapper.go`
- `apps/edge/internal/node/store.go`
- `apps/edge/internal/service/service.go`
- `apps/node/internal/transport/session.go`
- `apps/node/internal/transport/client.go`
- `apps/node/internal/bootstrap/module.go`
- `apps/node/internal/node/node.go`
- `apps/node/internal/node/run_manager.go`
- `apps/node/internal/router/router.go`
- `apps/node/internal/adapters/registry.go`
- `apps/node/internal/adapters/factory.go`
- `apps/node/internal/runtime/types.go`
- `apps/edge/internal/transport/server_test.go`
- `apps/edge/internal/bootstrap/runtime_test.go`
- `apps/node/internal/transport/session_test.go`
- `apps/node/internal/transport/integration_test.go`
- `apps/node/internal/adapters/factory_internal_test.go`
- `apps/node/internal/adapters/adapters_blackbox_test.go`
- `apps/node/internal/node/node_test.go`
- `apps/node/internal/node/node_concurrency_integration_test.go`
- `Makefile`

### SDD 기준

- SDD: `agent-roadmap/sdd/update-plane-self-update-foundation/runtime-reconnect-config-refresh/SDD.md`
- 상태: 승인됨, SDD 잠금 해제.
- 대상 Acceptance Scenario: S12 / Milestone Task `push-contract`.
- Evidence Map: S12는 "proto generated code plus Edge/Node handler tests"와 `push-contract`: refresh payload ack/failure contract verified를 요구한다.
- 구현 checklist는 S12를 만족하도록 proto request/response, generated Go code, Edge push method, Node request handler, handler tests를 포함한다. Adapter registry diff, drain, runtime config live apply는 S13-S15에 속하므로 이 계획에서 완료로 주장하지 않는다.

### 테스트 환경 규칙

- test_env: `local`.
- `agent-test/local/rules.md`를 읽었고, 경로상 `node-smoke`, `edge-smoke`, `platform-common-smoke`를 모두 매칭했다.
- 적용 규칙: Node 변경은 `go test ./apps/node/...`, Edge 변경은 `go test ./apps/edge/...`, proto 변경은 `make proto` 후 `go test ./packages/go/... ./proto/gen/...`.
- Makefile에서 `make proto`가 `proto/iop/runtime.proto`를 포함해 Go generated code를 갱신하는 것을 확인했다.
- full-cycle field 검증은 후속 field task 범위다. 이 계획은 unit/transport handler evidence로 종료한다.

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

- Proto refresh request/response: 현재 테스트 없음. `make proto`와 generated package compile을 추가 검증한다.
- Edge-to-Node refresh push: 현재 `apps/edge/internal/transport/server_test.go`는 registration 중심이고 refresh push 테스트가 없다. 연결 Node fake client 또는 transport integration test를 추가한다.
- Node refresh request handler: 현재 `apps/node/internal/transport/session_test.go`는 run/cancel/command 중심이고 config refresh request listener가 없다. request/response handler 테스트를 추가한다.
- 실제 adapter diff/live apply: 이 계획에서 의도적으로 제외하며 후속 `05+04_node_adapter_diff`가 테스트한다.

### 심볼 참조

- rename/remove 없음.
- 새 proto symbols: `NodeConfigRefreshRequest`, `NodeConfigRefreshResponse`, `NodeConfigRefreshStatus`.
- 새 handler method가 `transport.Handler`에 추가되면 call site는 `apps/node/internal/node/node.go`, tests의 fake handler들, session construction tests 전체를 갱신해야 한다.

### 분할 판단

- split decision policy를 먼저 평가했다. Protocol/API contract와 runtime apply/drain은 변경 소유권과 위험이 달라 다중 plan이 필요하다.
- 공유 task group: `m-runtime-reconnect-config-refresh`.
- sibling subtask:
  - `04+02_config_push_contract`: predecessor `02`는 archive `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/complete.log`로 충족.
  - `05+04_node_adapter_diff`: predecessor `04`는 현재 missing이며 이 plan PASS 후 진행.
  - `06+05_node_drain_runtime_config`: predecessor `05`는 현재 missing이며 adapter diff PASS 후 진행.

### 범위 결정 근거

- 포함: protobuf wire contract, generated Go code, Edge connected-node push/response collection, Node transport handler plumbing, S12 handler tests.
- 제외: adapter registry diff application, old adapter drain, runtime concurrency/workspace live apply, field smoke, docs. 이들은 S13-S18이며 후속 split plan 대상이다.
- Edge refresh classifier의 node adapter/runtime restart_required 정책은 이 plan에서 최소 변경만 허용한다. 실제 applied 분류 전환은 Node apply가 구현되는 plan에서 다룬다.

### 빌드 등급

- lane/GNN: `cloud-G07`.
- 이유: protobuf public contract, Edge/Node transport interface, generated code, tests가 동시에 바뀌는 cross-domain 작업이다.

## 구현 체크리스트

- [ ] `proto/iop/runtime.proto`에 backward-compatible refresh request/response 계약을 추가하고 `make proto`로 Go generated code를 갱신한다. 검증: generated proto와 `go test -count=1 ./packages/go/... ./proto/gen/...`가 통과한다.
- [ ] Edge transport parser와 Server에 connected Node refresh payload push 메서드를 추가하고 node별 `applied|restart_required|failed|skipped` 결과를 `configrefresh.Result`에 담는다. 검증: Edge handler 테스트가 request/response 수집과 disconnected node skip을 확인한다.
- [ ] Node transport session에 config refresh request listener를 추가하고 `transport.Handler`/`node.Node`가 ack/failure response를 반환하게 한다. 검증: Node session handler 테스트가 handler-ready, handler-missing, handler-error 응답을 확인한다.
- [ ] 전체 계약 회귀로 `make proto`, `go test -count=1 ./apps/edge/...`, `go test -count=1 ./apps/node/...`, `go test -count=1 ./packages/go/... ./proto/gen/...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## 의존 관계 및 구현 순서

- 이 subtask는 `04+02_config_push_contract`이므로 predecessor `02`만 요구한다.
- predecessor 충족: `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/complete.log`.
- 이 plan PASS 전에는 `05+04_node_adapter_diff`와 `06+05_node_drain_runtime_config` 구현을 시작하지 않는다.

### [NCPUSH-1] Refresh Wire Contract

#### 문제

`proto/iop/runtime.proto:165`의 `RegisterResponse`만 `NodeConfigPayload`를 담고, `proto/iop/runtime.proto:174`의 `NodeConfigPayload`는 "edge pushes"라고 설명하지만 실제 refresh request/ack message가 없다.

Before `proto/iop/runtime.proto:165`:

```proto
message RegisterResponse {
  bool              accepted = 1;
  string            node_id  = 2;
  string            alias    = 3;
  string            reason   = 4; // rejection reason
  NodeConfigPayload config   = 5;
}
```

#### 해결 방법

`RegisterResponse`와 `NodeConfigPayload` field number는 유지하고, 새 enum/message를 뒤에 추가한다. Response는 ack/failure/restart_required를 모두 표현해 S12를 만족시키고 후속 S13-S15가 같은 계약을 확장 없이 사용하게 한다.

After:

```proto
enum NodeConfigRefreshStatus {
  NODE_CONFIG_REFRESH_STATUS_UNSPECIFIED = 0;
  NODE_CONFIG_REFRESH_STATUS_APPLIED = 1;
  NODE_CONFIG_REFRESH_STATUS_RESTART_REQUIRED = 2;
  NODE_CONFIG_REFRESH_STATUS_FAILED = 3;
  NODE_CONFIG_REFRESH_STATUS_SKIPPED = 4;
}

message NodeConfigRefreshRequest {
  string request_id = 1;
  NodeConfigPayload config = 2;
  repeated string changed_paths = 3;
}

message NodeConfigRefreshResponse {
  string request_id = 1;
  NodeConfigRefreshStatus status = 2;
  repeated string restart_required_paths = 3;
  string error = 4;
}
```

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

- [ ] `proto/iop/runtime.proto`: 새 enum/message 추가, 기존 field number 변경 금지.
- [ ] `proto/gen/iop/runtime.pb.go`: `make proto` 결과만 반영.
- [ ] `Makefile` 수정 없음.

#### 테스트 작성

- 별도 unit test 대신 generated package compile을 검증한다. `platform-common-smoke`가 protobuf 원본 변경 시 `make proto`와 generated diff 확인을 요구한다.

#### 중간 검증

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

기대 결과: protoc generation 성공, generated package compile 성공.

### [NCPUSH-2] Edge Push And Result Reporting

#### 문제

`apps/edge/internal/transport/server.go:26`의 parser map은 refresh response를 모른다. `apps/edge/internal/bootstrap/runtime.go:197`의 `applyMutableConfig`는 `Server.SetNodeStore`, service/input swap만 하고 connected Node에 새 payload를 보내지 않는다. `apps/edge/internal/configrefresh/result.go:40`의 `Result`에는 SDD의 `refresh.node_results` 필드가 없다.

Before `apps/edge/internal/bootstrap/runtime.go:197`:

```go
func (r *Runtime) applyMutableConfig(candidate *config.EdgeConfig) error {
	nextStore, err := edgenode.LoadFromConfig(candidate.Nodes)
	if err != nil {
		return err
	}

	r.cfgMu.Lock()
	r.Cfg = candidate
	r.NodeStore = nextStore
	r.cfgMu.Unlock()

	r.Server.SetNodeStore(nextStore)
	r.Service.SetRuntimeConfig(nextStore, candidate.Models)
	r.Input.SetModelCatalog(candidate.Models)
	return nil
}
```

#### 해결 방법

`configrefresh.Result`에 `NodeResults []NodeResult`를 추가하고, apply mode에서 local copy-on-write commit 뒤 connected entries에 `NodeConfigRefreshRequest`를 보낸다. Transport push 실패는 local config rollback 사유로 삼지 않고 node result로 보고한다. Node가 연결되지 않았거나 registry entry/client가 없으면 `skipped`를 기록한다.

After:

```go
type NodeResult struct {
	NodeID string `json:"node_id"`
	Alias string `json:"alias,omitempty"`
	Status string `json:"status"`
	Error string `json:"error,omitempty"`
	RestartRequiredPaths []string `json:"restart_required_paths,omitempty"`
}

result, candidate, err := configrefresh.Evaluate(ctx, current, req)
...
if req.Mode == configrefresh.ModeApply && result.Status == configrefresh.StatusApplied && candidate != nil {
	nodeResults, err := r.applyMutableConfig(ctx, candidate, result.Changes)
	if err != nil {
		return configrefresh.RejectedResult(req, "apply candidate config: "+err.Error()), nil
	}
	result.NodeResults = nodeResults
}
```

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

- [ ] `apps/edge/internal/configrefresh/result.go`: `NodeResult`, `Result.NodeResults`, rejected result non-nil slice 추가.
- [ ] `apps/edge/internal/transport/server.go`: parser map에 `NodeConfigRefreshResponse` 추가, `PushConfigRefresh` 메서드 추가.
- [ ] `apps/edge/internal/bootstrap/runtime.go`: apply mode에서 node push 결과를 result에 병합.
- [ ] `apps/edge/internal/bootstrap/runtime_test.go`: apply result에 node results가 포함되는지 확인.
- [ ] `apps/edge/internal/transport/server_test.go` 또는 integration test: response/skip/failure 경로 확인.

#### 테스트 작성

- `TestRefreshConfigApplyIncludesNodeResults`: connected fake node가 ack하면 result JSON 구조에 status가 남는지 확인.
- `TestServerPushConfigRefreshSkippedWhenNodeDisconnected`: registry/client가 없으면 `skipped`를 반환하는지 확인.

#### 중간 검증

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

기대 결과: refresh result와 transport push tests PASS.

### [NCPUSH-3] Node Handler Ack/Failure Plumbing

#### 문제

`apps/node/internal/transport/session.go:15`의 `Handler`는 run/cancel/command만 처리한다. `apps/node/internal/transport/session.go:71`의 request listener도 `NodeCommandRequest`뿐이라 Edge가 config refresh request를 보내도 Node가 response를 만들 수 없다.

Before `apps/node/internal/transport/session.go:15`:

```go
type Handler interface {
	OnRunRequest(ctx context.Context, sess *Session, req *iop.RunRequest) error
	OnCancel(ctx context.Context, sess *Session, req *iop.CancelRequest) error
	OnCommandRequest(ctx context.Context, sess *Session, req *iop.NodeCommandRequest) (*iop.NodeCommandResponse, error)
}
```

#### 해결 방법

`Handler`에 `OnConfigRefresh`를 추가하고 session에 typed request listener를 등록한다. `node.Node`의 초기 구현은 payload nil/invalid는 `failed`, 현재 apply manager가 없는 변경은 `restart_required`, no-op 또는 후속 apply manager가 처리한 경우 `applied`로 응답할 수 있는 구조로 둔다. 실제 adapter registry update는 NCPUSH에서 하지 않는다.

After:

```go
type Handler interface {
	OnRunRequest(ctx context.Context, sess *Session, req *iop.RunRequest) error
	OnCancel(ctx context.Context, sess *Session, req *iop.CancelRequest) error
	OnCommandRequest(ctx context.Context, sess *Session, req *iop.NodeCommandRequest) (*iop.NodeCommandResponse, error)
	OnConfigRefresh(ctx context.Context, sess *Session, req *iop.NodeConfigRefreshRequest) (*iop.NodeConfigRefreshResponse, error)
}
```

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

- [ ] `apps/node/internal/transport/session.go`: parser map/listener/handler error response 추가.
- [ ] `apps/node/internal/node/node.go`: `OnConfigRefresh` method 추가. Adapter diff 적용은 TODO가 아니라 explicit `restart_required` response로 제한한다.
- [ ] `apps/node/internal/transport/session_test.go`: handler success/missing/error cases 추가.
- [ ] handler fake를 가진 기존 Node/transport tests compile 갱신.

#### 테스트 작성

- `TestSessionConfigRefreshRequestReturnsHandlerResponse`: request가 handler까지 도달하고 response가 반환된다.
- `TestSessionConfigRefreshRequestHandlerErrorReturnsFailure`: handler error가 protocol failure response로 변환된다.
- `TestNodeConfigRefreshWithoutApplyManagerReportsRestartRequired`: 실제 apply 미구현 변경은 restart_required로 보고된다.

#### 중간 검증

```bash
go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node
```

기대 결과: session request/response와 Node handler tests PASS.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `proto/iop/runtime.proto` | NCPUSH-1 |
| `proto/gen/iop/runtime.pb.go` | NCPUSH-1 |
| `apps/edge/internal/configrefresh/result.go` | NCPUSH-2 |
| `apps/edge/internal/transport/server.go` | NCPUSH-2 |
| `apps/edge/internal/bootstrap/runtime.go` | NCPUSH-2 |
| `apps/edge/internal/bootstrap/runtime_test.go` | NCPUSH-2 |
| `apps/edge/internal/transport/server_test.go` | NCPUSH-2 |
| `apps/node/internal/transport/session.go` | NCPUSH-3 |
| `apps/node/internal/node/node.go` | NCPUSH-3 |
| `apps/node/internal/transport/session_test.go` | NCPUSH-3 |
| `apps/node/internal/node/node_test.go` | NCPUSH-3 |

## 최종 검증

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

기대 결과: proto generation 성공, platform-common/edge/node package tests PASS. Go test cache output은 허용하지 않는다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
