<!-- task=m-control-plane-edge-wire-baseline/05+04_event_relay_boundary plan=1 tag=REVIEW_EVENTS -->

# Review Follow-up Plan - Stale Edge Event Guard

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

`CODE_REVIEW-cloud-G08.md`의 구현 에이전트 소유 섹션을 채우는 것까지가 구현입니다. 구현 후 검증을 실행하고 실제 변경 내용, 검증 출력, 계획 대비 변경 사항을 review stub에 기록한 뒤 active 파일을 그대로 둔 채 리뷰 준비를 보고하세요. 최종 판정, 로그 rename, `complete.log`, archive 이동은 code-review 스킬 책임입니다.

구현 중 사용자만 결정할 수 있는 범위 변경, 사용자 소유 외부 환경/secret, 또는 현재 계획과 충돌하는 요구가 발견되면 `CODE_REVIEW-cloud-G08.md`의 `사용자 리뷰 요청` 섹션에 정확한 결정 항목과 근거를 기록하고 중단하세요. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해결할 수 있는 검증 증거 공백은 사용자 리뷰 요청이 아니라 일반 후속 작업입니다.

## 배경

1차 구현은 Control Plane-Edge node lifecycle event relay 경로를 추가했지만, 리뷰에서 stale connection event 오염 가능성이 발견되었습니다. 기존 `EdgeServer`는 같은 `edge_id` 재연결 시 connection token으로 stale disconnect와 active client cleanup을 무시합니다. 새 `EdgeNodeEvent` listener도 같은 token invariant를 따라야 Control Plane의 recent event view가 현재 연결 기준으로 유지됩니다.

## 사용자 리뷰 요청 흐름

구현 중 차단 사유가 생기면 active `CODE_REVIEW-cloud-G08.md`의 `사용자 리뷰 요청` 섹션을 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 채웁니다. code-review 스킬이 그 요청을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정합니다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-edge-wire-baseline.md`
- Task ids:
  - `event-relay`: Edge의 node lifecycle event가 Control Plane까지 relay되는 최소 경로가 있다.
  - `service-boundary`: Edge wire handler는 node registry, run dispatch, command request 로직을 복제하지 않고 `apps/edge/internal/service`와 `events.Bus` 같은 표면 중립 경계를 사용한다.
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/private/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-roadmap/current.md`
- `agent-ops/rules/project/domain/control-plane/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/control-plane-smoke.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/platform-common-smoke.md`
- `agent-test/local/testing-smoke.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-task/m-control-plane-edge-wire-baseline/05+04_event_relay_boundary/plan_cloud_G08_0.log`
- `agent-task/m-control-plane-edge-wire-baseline/05+04_event_relay_boundary/code_review_cloud_G08_0.log`
- `agent-task/archive/2026/05/m-control-plane-edge-wire-baseline/04_status_snapshot/complete.log`
- `apps/control-plane/internal/wire/edge.go`
- `apps/control-plane/internal/wire/edge_registry.go`
- `apps/control-plane/internal/wire/edge_server.go`
- `apps/control-plane/internal/wire/edge_server_test.go`
- `apps/control-plane/internal/wire/edge_test.go`
- `apps/control-plane/cmd/control-plane/main.go`
- `apps/control-plane/cmd/control-plane/main_test.go`
- `apps/edge/internal/controlplane/connector.go`
- `apps/edge/internal/controlplane/connector_test.go`
- `apps/edge/internal/bootstrap/runtime.go`
- `apps/edge/internal/bootstrap/runtime_test.go`
- `apps/edge/internal/events/bus.go`

### 테스트 환경 규칙

- `test_env=local`로 판단했고 `agent-test/local/rules.md`를 읽었습니다.
- 이 규칙은 로컬 테스트/검증을 금지하고 기본 테스트 환경을 `ssh toki@toki-labs.com`의 code-server 컨테이너로 둡니다.
- 매칭 profile은 `control-plane-smoke`, `edge-smoke`, `platform-common-smoke`, `testing-smoke`입니다.
- 후속 검증 명령은 원격 code-server 컨테이너에서 실행합니다. 원격 접근이 실패하면 exact command와 stdout/stderr를 review stub에 기록하고, user-owned 외부 환경 prerequisite인지 code-review가 판단할 수 있게 남깁니다.
- `proto/iop/*.proto` 원본 변경은 계획하지 않으므로 `make proto`는 필수가 아닙니다. proto 원본을 바꾸는 선택을 하면 범위 이탈로 기록하고 `make proto`와 생성물 diff 검증을 추가해야 합니다.

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

- stale same-`edge_id` reconnection 뒤 오래된 첫 번째 connection이 `EdgeNodeEvent`를 보내도 recent event store에 기록되지 않는지 검증하는 테스트가 없습니다.
- current connection의 event가 여전히 정상 저장되는지 같은 테스트에서 함께 확인해야 합니다.

### 심볼 참조

- renamed/removed symbol: none. 새 token-aware helper를 추가하거나 `RecordNodeEvent` 내부를 분리할 수 있지만 기존 public test helper 성격의 call site를 불필요하게 깨지 않도록 합니다.

### 분할 판단

- 이 follow-up은 기존 split subtask `05+04_event_relay_boundary` 안의 1차 리뷰 Required issue만 해결합니다.
- 새 subtask로 분리하지 않습니다. 변경 범위가 `EdgeServer`/`EdgeRegistry` receive path와 해당 regression test에 좁게 묶여 있고, 기존 event relay 작업 완료 판단을 보완하는 성격입니다.
- predecessor `04_status_snapshot`은 `agent-task/archive/2026/05/m-control-plane-edge-wire-baseline/04_status_snapshot/complete.log`로 PASS 완료가 확인되었습니다.

### 범위 결정 근거

- Edge connector relay loop, Control Plane HTTP `/edges/{edge_id}/events` response shape, proto 계약, durable event history, ack/retry/backpressure policy는 변경하지 않습니다.
- 필요한 경우에만 `RecordNodeEvent` 구현을 private helper로 분리하고, server receive path에는 token-aware guard를 적용합니다.
- 검증 보완은 stale reconnect regression, focused wire package test, 기존 final verification 재확인에 한정합니다.

### 빌드 등급

- build lane: `cloud-G08`, review lane: `cloud-G08`.
- 근거: Control Plane-Edge wire concurrency/protocol invariant를 보완하는 follow-up이며, 기존 verification trust가 원격 실행 규칙 때문에 리뷰 재실행되지 않았습니다.

## 구현 체크리스트

- [ ] Control Plane `EdgeServer`/`EdgeRegistry`의 node event 기록 경로가 connection token을 확인해 superseded stale connection event를 무시하도록 수정한다.
- [ ] stale same-`edge_id` reconnection regression test를 추가해 stale connection event는 저장되지 않고 current connection event만 저장되는지 검증한다.
- [ ] 원격 테스트 환경 규칙에 맞춰 focused/final verification을 실행하고 실제 stdout/stderr를 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## REVIEW_EVENTS-1 Stale Connection Event Guard

### 문제

`apps/control-plane/internal/wire/edge_server.go:75`의 `EdgeNodeEvent` listener는 hello 이후 저장된 `edgeID`만 보고 `registry.RecordNodeEvent(id, event, time.Now())`를 호출합니다. 기존 `MarkDisconnected`와 active client cleanup은 connection `token`으로 stale reconnect를 방어하지만, `apps/control-plane/internal/wire/edge_registry.go:128`의 event 기록 경로에는 같은 guard가 없습니다.

결과적으로 같은 `edge_id`로 두 번째 connection이 supersede된 뒤에도 오래된 첫 번째 connection이 닫히지 않은 상태에서 event를 보내면 Control Plane recent event store가 stale event를 받아들일 수 있습니다.

### 해결 방법

`EdgeServer` listener에서 `edgeID`와 `token`을 함께 캡처하고, registry lock 안에서 현재 tracked state의 token과 일치할 때만 event를 append하는 token-aware path를 추가합니다. 기존 tests가 직접 seed 용도로 쓰는 `RecordNodeEvent(edgeID, event, receivedAt)`를 유지하려면 private helper를 분리하고 server path에서만 token guard variant를 사용합니다.

예상 형태:

```go
func (r *EdgeRegistry) RecordNodeEventFromConnection(edgeID string, token uint64, event *iop.EdgeNodeEvent, receivedAt time.Time) bool {
    return r.recordNodeEvent(edgeID, token, true, event, receivedAt)
}
```

server listener:

```go
idMu.Lock()
id := edgeID
tok := token
idMu.Unlock()
if id == "" {
    return
}
registry.RecordNodeEventFromConnection(id, tok, event, time.Now())
```

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

- [ ] `apps/control-plane/internal/wire/edge_registry.go`: token-aware record path 추가, 기존 copy/timestamp/bounded behavior 유지.
- [ ] `apps/control-plane/internal/wire/edge_server.go`: event listener가 `edgeID`와 `token`을 함께 전달하도록 수정.
- [ ] `apps/control-plane/internal/wire/edge_server_test.go`: stale reconnect event regression 추가.

### 테스트 작성

- `TestEdgeServerStaleConnectionNodeEventDoesNotPolluteReconnect` 또는 기존 `TestEdgeServerStaleDisconnectDoesNotClearReconnect` 확장.
- fixture: 같은 `edgeID`로 `conn1` hello, `conn2` hello를 순서대로 보낸다.
- assertion: `conn1.Send(EdgeNodeEvent{EventId:"evt-stale"})` 후 registry에 event가 없어야 한다. `conn2.Send(EdgeNodeEvent{EventId:"evt-current"})` 후 registry에는 current event만 있어야 한다.

### 중간 검증

```bash
ssh toki@toki-labs.com 'cd /config/workspace/iop && go test -count=1 ./apps/control-plane/internal/wire'
```

기대 결과: stale reconnect event regression과 기존 wire tests가 통과합니다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/control-plane/internal/wire/edge_registry.go` | REVIEW_EVENTS-1 |
| `apps/control-plane/internal/wire/edge_server.go` | REVIEW_EVENTS-1 |
| `apps/control-plane/internal/wire/edge_server_test.go` | REVIEW_EVENTS-1 |

## 최종 검증

```bash
ssh toki@toki-labs.com 'cd /config/workspace/iop && go test -count=1 ./apps/control-plane/internal/wire'
ssh toki@toki-labs.com 'cd /config/workspace/iop && go test -count=1 ./apps/control-plane/cmd/control-plane ./apps/edge/internal/controlplane ./apps/edge/internal/bootstrap ./apps/edge/internal/events'
ssh toki@toki-labs.com 'cd /config/workspace/iop && go test -race -count=1 ./apps/edge/internal/controlplane ./apps/control-plane/internal/wire'
ssh toki@toki-labs.com 'cd /config/workspace/iop && go test ./...'
ssh toki@toki-labs.com 'cd /config/workspace/iop && make test-control-plane-edge-wire'
ssh toki@toki-labs.com 'cd /config/workspace/iop && (rg --sort path -n "apps/edge/internal/node|NodeCommandRequest|RunRequest" apps/edge/internal/controlplane; test $? -eq 1)'
```

기대 결과: 모든 Go/race/smoke 명령이 성공하고, 마지막 boundary check는 match 없이 exit code 0 wrapper로 끝납니다.

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