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

# Control Plane-Edge Event Relay And Boundary Plan

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

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

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

## 배경

현 마일스톤의 3-4번째 Epic에는 Edge의 node lifecycle event가 Control Plane까지 도달하고, Edge wire handler가 Edge 내부 구현을 복제하지 않는 boundary가 남아 있습니다. Status snapshot 작업이 끝나면 Control Plane-Edge connector는 양방향 message를 다룰 수 있으므로, 이 작업은 그 구조 위에 `events.Bus` 기반 live relay와 Control Plane의 최소 recent-event 관찰 표면을 붙입니다.

## 사용자 리뷰 요청 흐름

구현 중 차단 사유가 생기면 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-roadmap/current.md`
- `agent-roadmap/phase/control-plane-portal-ops/PHASE.md`
- `agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-edge-wire-baseline.md`
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.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-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/project/e2e-smoke/SKILL.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`
- `proto/iop/control.proto`
- `proto/iop/runtime.proto`
- `proto/iop/node.proto`
- `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_test.go`
- `apps/control-plane/internal/wire/edge_server_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/service/service.go`
- `apps/edge/internal/service/service_test.go`
- `apps/edge/internal/events/bus.go`
- `apps/edge/internal/events/bus_test.go`
- `apps/edge/internal/bootstrap/runtime.go`
- `apps/edge/internal/bootstrap/runtime_test.go`
- `apps/edge/internal/node/registry.go`
- `apps/edge/internal/node/registry_test.go`
- `scripts/e2e-control-plane-edge-wire.sh`
- `Makefile`
- `go.mod`

### 테스트 환경 규칙

- `test_env=local`로 판단했고 `agent-test/local/rules.md`를 읽었습니다.
- 매칭 profile은 `control-plane-smoke`, `edge-smoke`, `platform-common-smoke`, `testing-smoke`입니다.
- 적용 명령은 `go test ./apps/control-plane/...`, `go test ./apps/edge/...`, `go test ./...`, `make test-control-plane-edge-wire`입니다.
- 이 작업은 기존 `runtime.proto`의 `EdgeNodeEvent`를 사용하므로 새 protobuf 원본 변경이 없다면 `make proto`는 필수가 아닙니다. proto를 바꾸는 선택을 했다면 `platform-common-smoke`에 따라 `make proto`를 추가 실행합니다.
- `make test-control-plane-edge-wire`는 hello/connect/disconnect smoke이며 event relay 자체를 검증하지 않습니다. event relay 검증은 focused Go integration test에 둡니다.

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

- Edge event relay: `events.Bus.SubscribeAllNodes()`와 connector를 연결하는 테스트가 없습니다.
- Control Plane receive path: `EdgeServer`가 `EdgeNodeEvent`를 수신해 edge id와 함께 저장하는 테스트가 없습니다.
- Recent event observation: Control Plane HTTP `/edges`는 registry state만 보여주며 relayed node lifecycle event를 조회하는 표면이 없습니다.
- Boundary: connector가 `events.Bus`와 `Service` 같은 표면 중립 dependency만 쓰고 node registry/client internals를 직접 복제하지 않는 회귀 테스트가 없습니다.

### 심볼 참조

- renamed/removed symbol: none. 새 event relay method와 registry event snapshot API를 추가하는 작업입니다.

### 분할 판단

- split decision policy를 plan 파일 선택 전에 평가했습니다.
- 공유 task group은 `m-control-plane-edge-wire-baseline`입니다.
- `04_status_snapshot`은 status request/response foundation이며 이 작업의 선행 조건입니다.
- `05+04_event_relay_boundary`는 `04_status_snapshot` 완료 후 같은 connector/parser 구조에 event relay를 추가합니다.
- event fanout, Control Plane receive registry, HTTP observation, boundary 검증은 status foundation과 다른 위험 profile이므로 별도 subtask로 분리합니다.

### 범위 결정 근거

- Control Plane DB 저장, durable event history, ack/retry delivery, backpressure policy, multi-edge federation은 제외합니다.
- Run event stream relay는 제외하고 node lifecycle/control event인 `EdgeNodeEvent`만 다룹니다.
- Edge command/run dispatch/status command 로직은 새로 복제하지 않고, 필요한 상태는 `events.Bus`와 status provider가 제공한 DTO에 한정합니다.
- 기본 local smoke script는 유지합니다. event relay success는 deterministic Go test로 검증합니다.

### 빌드 등급

- build lane: `cloud-G08`, review lane: `cloud-G08`.
- 근거: event fanout/concurrency와 Control Plane-Edge protocol handler를 함께 바꾸는 cross-domain 작업이며, status subtask에 의존하는 boundary 검증이 필요합니다.

## 구현 체크리스트

- [ ] `04_status_snapshot` subtask가 PASS 완료된 뒤에만 시작한다. runtime이 이 subtask를 시작했다면 의존성이 충족된 것으로 보고, 수동 실행이면 predecessor PASS evidence 또는 runtime 완료 이벤트 경로를 review stub에 기록한다.
- [ ] Control Plane `EdgeParserMap`/`EdgeServer`가 `EdgeNodeEvent`를 수신해 edge id와 함께 bounded recent event store에 기록하도록 구현한다.
- [ ] Edge `controlplane.Connector`가 `events.Bus.SubscribeAllNodes()`를 통해 node lifecycle event를 Control Plane으로 relay하도록 option과 bootstrap wiring을 추가한다.
- [ ] Control Plane에서 relayed node lifecycle event를 조회할 최소 observation surface와 tests를 추가하되, Edge internal registry/run/command 로직을 복제하지 않는다.
- [ ] Event relay, recent event snapshot, service/events boundary 회귀 테스트를 추가하고 대상 Go 테스트 및 기존 local wire smoke를 통과시킨다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## EVENTS-1 Dependency Gate And Parser

### 문제

이 subtask는 `05+04_event_relay_boundary` 이름으로 `04_status_snapshot`에 의존합니다. 또한 `apps/control-plane/internal/wire/edge.go:13`-`21`의 `EdgeParserMap()`은 현재 hello message만 파싱하므로 Edge가 `runtime.proto`의 `EdgeNodeEvent`를 보내도 Control Plane이 받을 수 없습니다.

현재 코드:

```go
// apps/control-plane/internal/wire/edge.go:13
return proto_socket.ParserMap{
    proto_socket.TypeNameOf(&iop.EdgeHelloRequest{}): func(b []byte) (proto.Message, error) {
        req := &iop.EdgeHelloRequest{}
        return req, proto.Unmarshal(b, req)
    },
    proto_socket.TypeNameOf(&iop.EdgeHelloResponse{}): func(b []byte) (proto.Message, error) {
        res := &iop.EdgeHelloResponse{}
        return res, proto.Unmarshal(b, res)
    },
}
```

### 해결 방법

구현 시작 시 runtime dependency가 충족되었는지 확인합니다. runtime이 이 subtask를 시작했다면 `04_status_snapshot`이 PASS된 것으로 보고, 수동 실행이면 predecessor PASS evidence 또는 runtime 완료 이벤트 경로를 review stub에 기록합니다. 완료 전이면 code changes를 하지 말고 dependency 대기 상태를 기록합니다. 완료되어 있으면 `EdgeParserMap()`에 `iop.EdgeNodeEvent` parser를 추가하고 roundtrip test를 작성합니다.

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

- [ ] `04_status_snapshot` predecessor PASS evidence 또는 runtime 완료 이벤트 경로: 선행 완료 확인.
- [ ] `apps/control-plane/internal/wire/edge.go`: `EdgeNodeEvent` parser 추가.
- [ ] `apps/control-plane/internal/wire/edge_test.go`: parser roundtrip 추가.

### 테스트 작성

- `TestEdgeParserMapNodeEventRoundTrip`를 추가합니다.
- assertion: `event_id`, `type`, `node_id`, `alias`, `metadata`, `timestamp`가 roundtrip됩니다.

### 중간 검증

```bash
go test -count=1 ./apps/control-plane/internal/wire
```

기대 결과: review stub에 선행 PASS evidence가 기록되고 parser tests가 통과합니다.

## EVENTS-2 Control Plane Receive And Recent Event Store

### 문제

`apps/control-plane/internal/wire/edge_server.go:62`-`107`은 hello와 disconnect만 처리하고, 연결 context의 `edgeID`에 묶인 `EdgeNodeEvent` listener가 없습니다. `apps/control-plane/internal/wire/edge_registry.go:12`-`24`의 state에는 lifecycle event 보관 표면도 없습니다.

현재 코드:

```go
// apps/control-plane/internal/wire/edge_server.go:93
client.AddDisconnectListener(func(c *proto_socket.TcpClient) {
    idMu.Lock()
    id := edgeID
    tok := token
    idMu.Unlock()
    if id == "" {
        return
    }
    info := c.DisconnectInfo()
    registry.MarkDisconnected(id, tok, time.Now(), info)
})
```

### 해결 방법

hello로 식별된 connection context에서 `EdgeNodeEvent` listener를 등록하고, registry에 bounded recent events를 edge id와 함께 기록합니다. timestamp가 비어 있으면 Control Plane receive time을 채우고, event 자체는 copy/clone해서 caller mutation이 registry를 오염시키지 않게 합니다.

예상 형태:

```go
proto_socket.AddListenerTyped[*iop.EdgeNodeEvent](&client.Communicator, func(event *iop.EdgeNodeEvent) {
    idMu.Lock()
    id := edgeID
    idMu.Unlock()
    if id == "" {
        return
    }
    registry.RecordNodeEvent(id, event, time.Now())
})
```

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

- [ ] `apps/control-plane/internal/wire/edge_registry.go`: `EdgeNodeEventRecord`, `RecordNodeEvent`, `NodeEvents(edgeID, nodeID string)` 추가.
- [ ] `apps/control-plane/internal/wire/edge_server.go`: `EdgeNodeEvent` listener 추가.
- [ ] `apps/control-plane/internal/wire/edge_server_test.go`: relayed event 수신/저장 테스트 추가.

### 테스트 작성

- `TestEdgeServerRecordsNodeLifecycleEventFromEdge`를 추가합니다.
- hello 전 event는 무시하고, hello 후 `node.connected` event는 `edgeID` 아래에 저장되는지 확인합니다.
- returned event slice를 mutate해도 registry 내부가 바뀌지 않는지 확인합니다.

### 중간 검증

```bash
go test -count=1 ./apps/control-plane/internal/wire
```

기대 결과: event receive/store tests와 기존 hello/disconnect tests가 통과합니다.

## EVENTS-3 Edge Events.Bus Relay

### 문제

`apps/edge/internal/events/bus.go:123`-`138`은 all-node subscriber를 제공하고, `apps/edge/internal/bootstrap/runtime.go:85`-`87`은 transport node events를 bus에 publish합니다. 하지만 `apps/edge/internal/controlplane/connector.go:223`-`230`은 Control Plane connection이 열린 동안 disconnect를 기다릴 뿐, bus events를 Control Plane으로 보내지 않습니다.

현재 코드:

```go
// apps/edge/internal/bootstrap/runtime.go:85
func (r *Runtime) wireHandlers() {
    r.Server.SetRunEventHandler(r.EventBus.PublishRun)
    r.Server.SetNodeEventHandler(r.EventBus.PublishNode)
}
```

### 해결 방법

Connector에 `WithNodeEventBus(bus *edgeevents.Bus)` option을 추가하고, accepted Control Plane connection lifetime 동안 `SubscribeAllNodes()`를 사용해 live node lifecycle events를 `TcpClient.Send(event)`로 relay합니다. Event metadata에 `edge_id`가 없으면 copy에만 채우고 원본 bus event는 mutate하지 않습니다.

예상 형태:

```go
func (c *Connector) startNodeEventRelay(ctx context.Context, cl *toki.TcpClient) func() {
    if c.nodeEvents == nil {
        return func() {}
    }
    events, unsubscribe := c.nodeEvents.SubscribeAllNodes(64)
    go func() {
        defer unsubscribe()
        for {
            select {
            case <-ctx.Done():
                return
            case event, ok := <-events:
                if !ok {
                    return
                }
                _ = cl.Send(enrichEdgeMetadata(event, c.edgeInfo.ID))
            }
        }
    }()
    return unsubscribe
}
```

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

- [ ] `apps/edge/internal/controlplane/connector.go`: node event bus option, relay loop, event copy/enrichment helper 추가.
- [ ] `apps/edge/internal/bootstrap/runtime.go`: `WithNodeEventBus(bus)` wiring.
- [ ] `apps/edge/internal/controlplane/connector_test.go`: fake Control Plane이 `EdgeNodeEvent`를 받는 테스트 추가.
- [ ] `apps/edge/internal/bootstrap/runtime_test.go`: runtime wiring 회귀 테스트 추가.

### 테스트 작성

- `TestConnectorRelaysNodeLifecycleEventsToControlPlane`를 추가합니다.
- fake CP server가 hello 후 `EdgeNodeEvent` listener를 등록하고, Edge `events.Bus.PublishNode()` 호출 후 event를 받는지 확인합니다.
- event copy에 edge metadata가 들어가고 원본 event metadata는 mutation되지 않는지 확인합니다.

### 중간 검증

```bash
go test -count=1 ./apps/edge/internal/controlplane ./apps/edge/internal/bootstrap ./apps/edge/internal/events
```

기대 결과: connector relay, bootstrap wiring, events bus tests가 통과합니다.

## EVENTS-4 Observation Surface And Boundary Guard

### 문제

`apps/control-plane/cmd/control-plane/main.go:223`-`258`의 HTTP handlers는 `/edges`와 `/edges/{edge_id}` registry state만 반환합니다. Relayed lifecycle event를 Control Plane 운영 표면에서 확인할 최소 조회 경로가 없으면 relay가 제품 운영 surface로 이어졌는지 검증하기 어렵습니다.

현재 코드:

```go
// apps/control-plane/cmd/control-plane/main.go:241
mux.HandleFunc("/edges/", func(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodGet {
        w.Header().Set("Allow", http.MethodGet)
        http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
        return
    }
    edgeID := strings.TrimPrefix(r.URL.Path, "/edges/")
    // ...
})
```

### 해결 방법

`GET /edges/{edge_id}/events`를 최소 observation surface로 추가하고, registry의 bounded recent node events를 JSON으로 반환합니다. 이 endpoint는 Control Plane에 도착한 event를 보여줄 뿐, Edge node registry나 command/run dispatch 로직을 재구현하지 않습니다.

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

- [ ] `apps/control-plane/cmd/control-plane/main.go`: event view DTO와 `/edges/{edge_id}/events` handler 추가.
- [ ] `apps/control-plane/cmd/control-plane/main_test.go`: list/get/events handler tests 추가.
- [ ] `apps/edge/internal/controlplane/connector.go`: node registry/run dispatch/command request 직접 import가 없는지 유지.

### 테스트 작성

- `TestEdgeRegistryHTTPHandlersListNodeEvents`를 추가합니다.
- registry에 edge와 node event를 seed하고 `/edges/edge-a/events`가 event id/type/node/alias/reason/timestamp/metadata를 반환하는지 확인합니다.
- `/edges/edge-a/events/extra`나 unknown edge는 404를 반환합니다.

### 중간 검증

```bash
go test -count=1 ./apps/control-plane/cmd/control-plane ./apps/control-plane/internal/wire
rg --sort path -n "apps/edge/internal/node|NodeCommandRequest|RunRequest" apps/edge/internal/controlplane
```

기대 결과: HTTP tests가 통과하고, `apps/edge/internal/controlplane`에는 node internals 또는 command/run dispatch 복제 import가 없습니다. 단, proto type 이름이 test fixture에 필요한 경우는 계획 대비 변경 사항에 이유를 기록합니다.

## EVENTS-5 Regression And Smoke

### 문제

Event relay는 goroutine, disconnect lifetime, bounded buffers가 걸려 있어 unit 통과만으로 lifecycle 회귀를 놓칠 수 있습니다. `agent-test/local/control-plane-smoke.md:55`는 Control Plane-Edge TCP wire 변경 시 existing local smoke 실행을 요구합니다.

### 해결 방법

focused tests에 `-race`를 포함해 relay loop/data race를 확인하고, 전체 Go 회귀 및 existing local Control Plane-Edge smoke를 실행합니다. 기존 smoke가 event 자체를 확인하지 않는다는 한계는 review stub에 남기고, event success는 focused integration tests 출력으로 증명합니다.

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

- [ ] focused package tests를 실행합니다.
- [ ] race test를 relay 관련 package에 실행합니다.
- [ ] 전체 Go 회귀와 existing local wire smoke를 실행합니다.

### 테스트 작성

- 새 테스트는 EVENTS-1~4 항목에 둡니다.
- 기존 smoke script 수정은 이 plan 범위 밖입니다.

### 중간 검증

```bash
go test -race -count=1 ./apps/edge/internal/controlplane ./apps/control-plane/internal/wire
go test ./...
make test-control-plane-edge-wire
```

기대 결과: race/focused/full tests와 existing local wire smoke가 통과합니다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/control-plane/internal/wire/edge.go` | EVENTS-1 |
| `apps/control-plane/internal/wire/edge_test.go` | EVENTS-1 |
| `apps/control-plane/internal/wire/edge_registry.go` | EVENTS-2 |
| `apps/control-plane/internal/wire/edge_server.go` | EVENTS-2 |
| `apps/control-plane/internal/wire/edge_server_test.go` | EVENTS-2 |
| `apps/edge/internal/controlplane/connector.go` | EVENTS-3, EVENTS-4 |
| `apps/edge/internal/controlplane/connector_test.go` | EVENTS-3 |
| `apps/edge/internal/bootstrap/runtime.go` | EVENTS-3 |
| `apps/edge/internal/bootstrap/runtime_test.go` | EVENTS-3 |
| `apps/control-plane/cmd/control-plane/main.go` | EVENTS-4 |
| `apps/control-plane/cmd/control-plane/main_test.go` | EVENTS-4 |

## 의존 관계 및 구현 순서

이 subtask directory는 `05+04_event_relay_boundary`이므로 runtime dependency source of truth는 `04_status_snapshot`입니다. runtime이 이 subtask를 시작했다면 선행 PASS가 충족된 것으로 보고, 수동 실행이면 predecessor PASS evidence 또는 runtime 완료 이벤트 경로를 먼저 기록합니다. 완료 후 EVENTS-1 -> EVENTS-2 -> EVENTS-3 -> EVENTS-4 -> EVENTS-5 순서로 진행합니다.

## 최종 검증

```bash
go test -count=1 ./apps/control-plane/internal/wire
go test -count=1 ./apps/control-plane/cmd/control-plane
go test -count=1 ./apps/edge/internal/controlplane ./apps/edge/internal/bootstrap ./apps/edge/internal/events
go test -race -count=1 ./apps/edge/internal/controlplane ./apps/control-plane/internal/wire
go test ./...
make test-control-plane-edge-wire
rg --sort path -n "apps/edge/internal/node|NodeCommandRequest|RunRequest" apps/edge/internal/controlplane
```

기대 결과: 선행 PASS evidence가 review stub에 기록되고 모든 Go/race/smoke 명령이 성공합니다. 마지막 `rg`는 controlplane package가 Edge node internals 또는 run/command dispatch 로직을 직접 복제하지 않았다는 boundary check로 사용하며, 의도된 proto type 언급이 있으면 review stub에 근거를 기록합니다.

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