<!-- task=m-proto-socket-infrastructure-communication-rail/01_core_endpoint plan=0 tag=CORE-ENDPOINT -->

# Plan - CORE-ENDPOINT

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

구현 완료의 마지막 단계는 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이다. 검증 명령을 실행하고 실제 stdout/stderr를 남긴 뒤 active 파일을 그대로 둔 채 리뷰 준비 상태를 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review-skill 전용이다.

구현 중 사용자만 결정할 수 있는 사항, 외부 환경 준비, 또는 계획 범위 충돌로 진행할 수 없으면 review stub의 `사용자 리뷰 요청` 섹션에 정확한 결정 필요 사항, 근거, 실행한 명령과 출력, 재개 조건을 채우고 중단한다. 구현 에이전트는 `USER_REVIEW.md`, archive log, `complete.log`를 직접 만들지 않는다.

## 배경

현재 Core는 REST health/readiness와 `/api/tasks` 계열만 제공한다. 이번 Milestone의 두 번째 Epic은 Core가 내부 런타임 통신용 `proto-socket` endpoint를 같은 HTTP server lifecycle 안에서 제공하고, 이후 task channel 구현이 올라갈 확장 지점을 만드는 것이다. 첫 구현 단위는 기존 REST 표면을 유지하면서 인증된 proto-socket 연결 endpoint와 공통 envelope/dispatcher 기반을 추가한다.

## 사용자 리뷰 요청 흐름

구현 중 차단 사항은 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 이 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 내용을 사용하며, code-review가 검증한 뒤 실제 `USER_REVIEW.md` 작성 여부를 결정한다.

## 분석 결과

### 읽은 파일

- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/private/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/rules/common/philosophy.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/update-roadmap/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-test/local/rules.md`
- `agent-test/local/scenarios/core-smoke.md`
- `agent-test/local/scenarios/contracts-smoke.md`
- `agent-test/local/scenarios/workspace-ops-smoke.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/workflow-core/PHASE.md`
- `agent-roadmap/phase/workflow-core/milestones/proto-socket-infrastructure-communication-rail.md`
- `packages/contracts/notes/flutter-core-api-candidates.md`
- `services/core/go.mod`
- `services/core/cmd/server/main.go`
- `services/core/internal/config/config.go`
- `services/core/internal/config/config_test.go`
- `services/core/internal/http/router.go`
- `services/core/internal/http/handlers.go`
- `services/core/internal/http/handlers_test.go`
- `services/core/internal/http/middleware.go`
- `services/core/internal/http/middleware_test.go`
- `services/core/internal/workflow/model.go`
- `services/core/internal/workflow/service.go`
- `services/core/internal/storage/store.go`
- `services/core/internal/db/models.go`
- `services/core/internal/db/tasks.sql.go`
- `services/core/queries/tasks.sql`
- `services/core/internal/scheduler/river.go`
- `services/core/internal/scheduler/jobs_test.go`
- `services/core/bin/test`
- `../proto-socket/AGENTS.md`
- `../proto-socket/README.md`
- `../proto-socket/PROTOCOL.md`
- `../proto-socket/go/go.mod`
- `../proto-socket/go/base_client.go`
- `../proto-socket/go/communicator.go`
- `../proto-socket/go/heartbeat_timer.go`
- `../proto-socket/go/ws_client.go`
- `../proto-socket/go/ws_server.go`
- `../proto-socket/go/packets/message_common.pb.go`
- `../proto-socket/go/test/ws_test.go`
- `../proto-socket/go/examples/ws_echo/main.go`
- `../proto-socket/proto/message_common.proto`
- `../proto-socket/examples/go-module-consumer/go.mod`
- `../proto-socket/examples/go-module-consumer/README.md`

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

- proto-socket dependency/config: 기존 `config_test.go`는 A2A/model/workflow timeout만 검증하며 `PROTO_SOCKET_*` 설정은 없다.
- proto-socket route/auth: 기존 `middleware_test.go`는 `/healthz` public, `/api/tasks` protected만 검증한다. `/proto-socket` 보호 여부와 REST health/readiness 유지 검증이 없다.
- proto-socket envelope/dispatcher: 현재 `services/core`에는 proto-socket package가 없어 parser map, semantic envelope round trip, unsupported action error envelope 테스트가 없다.
- websocket lifecycle: 현재 Core HTTP server에는 websocket accept/client close 경로가 없어 연결 수명과 shutdown close 테스트가 없다.

### 심볼 참조

- 제거/rename 대상 없음.
- `NewRouter`는 signature를 확장해야 한다. 현재 참조는 `services/core/cmd/server/main.go:92`, `services/core/internal/http/middleware_test.go:47`, 정의는 `services/core/internal/http/router.go:16`이다.
- `Config`는 필드 추가만 한다. 기존 참조 제거 또는 rename 없음.

### 분할 판단

Split decision policy를 plan 파일 선택 전에 평가했다. 공유 task group은 `m-proto-socket-infrastructure-communication-rail`이다.

- `01_core_endpoint`: Core HTTP server 안에 proto-socket endpoint, dependency, config, envelope, dispatcher skeleton, lifecycle close를 추가한다. 독립 검증은 연결 endpoint/auth/heartbeat/error envelope다.
- `02+01_task_channels`: `01_core_endpoint` 완료 후 task create/list/get/enqueue와 status event fanout을 붙인다. directory name의 `+01`이 predecessor dependency이며 `01_core_endpoint`의 `complete.log`가 필요하다.

이 Epic은 protocol/auth/lifecycle 기반과 workflow task channel rollout이 서로 다른 risk/test profile을 가지므로 단일 계획으로 묶지 않는다.

### 범위 결정 근거

- Flutter client connector는 후속 `[client-rail]` Epic 범위라 수정하지 않는다.
- REST `/healthz`, `/readyz`, `/api/tasks`는 compatibility/smoke 표면이므로 제거하거나 의미를 바꾸지 않는다.
- Task action 구현과 notification fanout은 dependent plan `02+01_task_channels`로 미룬다. 이 계획은 dispatcher skeleton과 unsupported action error까지만 만든다.
- `../proto-socket` sibling repo는 참조만 하고 수정하지 않는다.
- Source schema 승격(`.proto` 추가/generated code)은 제외한다. 현재 contract note가 semantic envelope 후보를 source schema 전 단계로 명시하고 있어 `structpb.Struct` envelope로 시작한다.

### 빌드 등급

`cloud-G08`. 새 protocol dependency, websocket lifecycle, auth route, dispatcher/error contract, cross-package server wiring이 포함되고 기존 테스트 공백이 크다.

## 구현 체크리스트

- [ ] `services/core`에 proto-socket dependency와 `PROTO_SOCKET_*` 설정을 추가한다.
- [ ] Core HTTP server에 protected proto-socket endpoint, semantic envelope/parser/dispatcher skeleton, lifecycle close를 구현한다. 검증: Core가 기존 REST health/readiness를 유지하면서 proto-socket 연결 endpoint를 노출한다.
- [ ] endpoint/config/envelope/auth/error/heartbeat 테스트와 contract note endpoint 경로를 추가한다.
- [ ] 허용된 비로컬 테스트 환경에서 중간/최종 검증 명령을 `-count=1`로 실행하고 실제 출력을 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## 의존 관계 및 구현 순서

이 subtask는 dependency가 없는 `01_core_endpoint`다. 이 계획이 PASS되어 `complete.log`가 생긴 뒤 `02+01_task_channels`를 시작한다.

### [CORE-ENDPOINT-1] proto-socket dependency and configuration

#### 문제

`services/core/go.mod:5-10`에는 Core에서 직접 사용할 `proto-socket`, `google.golang.org/protobuf`, `nhooyr.io/websocket` dependency가 없다. `services/core/internal/config/config.go:8-29`와 `config.go:32-55`에도 endpoint path와 heartbeat 설정이 없다.

#### 해결 방법

`../proto-socket/examples/go-module-consumer/go.mod:5-10`의 module/version을 기준으로 Core manifest에 dependency를 추가한다. 허용된 검증 환경에서 private module fetch가 안 되면 local `replace`를 임의 커밋하지 말고 `사용자 리뷰 요청`을 기록한다.

Before:

```go
// services/core/go.mod:5
require (
	github.com/go-chi/chi/v5 v5.2.5
	github.com/jackc/pgx/v5 v5.9.2
	github.com/riverqueue/river v0.37.1
	github.com/riverqueue/river/riverdriver/riverpgxv5 v0.37.1
)
```

After:

```go
require (
	git.toki-labs.com/toki/proto-socket/go v0.0.0-20260501220005-284b66a22300
	github.com/go-chi/chi/v5 v5.2.5
	github.com/jackc/pgx/v5 v5.9.2
	github.com/riverqueue/river v0.37.1
	github.com/riverqueue/river/riverdriver/riverpgxv5 v0.37.1
	google.golang.org/protobuf v1.36.5
	nhooyr.io/websocket v1.8.17
)
```

Config는 explicit env 이름을 추가한다.

Before:

```go
// services/core/internal/config/config.go:25
MattermostBaseURL      string
MattermostToken        string
PlaneBaseURL           string
PlaneToken             string
WorkflowTaskTimeoutSec int
```

After:

```go
MattermostBaseURL              string
MattermostToken                string
PlaneBaseURL                   string
PlaneToken                     string
WorkflowTaskTimeoutSec         int
ProtoSocketPath                string
ProtoSocketHeartbeatIntervalSec int
ProtoSocketHeartbeatWaitSec     int
```

Load defaults:

```go
ProtoSocketPath:                getEnv("PROTO_SOCKET_PATH", "/proto-socket"),
ProtoSocketHeartbeatIntervalSec: getEnvInt("PROTO_SOCKET_HEARTBEAT_INTERVAL_SEC", 30),
ProtoSocketHeartbeatWaitSec:     getEnvInt("PROTO_SOCKET_HEARTBEAT_WAIT_SEC", 10),
```

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

- [ ] `services/core/go.mod`: direct requirements를 추가하고 `go mod tidy` 결과에 맞춰 `go.sum`을 갱신한다.
- [ ] `services/core/internal/config/config.go`: `Config` 필드와 `Load()` defaults를 추가한다.
- [ ] `services/core/internal/config/config_test.go`: default와 env override 테스트를 추가한다.

#### 테스트 작성

- 작성: `services/core/internal/config/config_test.go`에 `TestConfigLoadsProtoSocketDefaultsAndOverrides`를 추가한다. 기본값 `/proto-socket`, `30`, `10`과 env override 값을 검증한다.

#### 중간 검증

로컬 테스트 실행은 `agent-test/local/rules.md`에 따라 금지다. 허용된 비로컬 테스트 환경에서 실행한다.

```bash
cd services/core && go test -count=1 ./internal/config
```

예상 결과: proto-socket config 테스트 포함 전체 package PASS.

### [CORE-ENDPOINT-2] protected endpoint, envelope, dispatcher, lifecycle

#### 문제

`services/core/internal/http/router.go:16-36`은 `/healthz`, `/readyz`, `/api`만 등록한다. `services/core/cmd/server/main.go:88-93`은 HTTP handler만 wire하고 websocket client lifecycle을 닫는 코드가 없다. proto-socket `WsClient`는 `../proto-socket/go/ws_client.go:31-55`처럼 accepted websocket conn과 parser map으로 생성할 수 있고, request listener는 `../proto-socket/go/communicator.go:333-355`에서 한 message type당 하나만 등록된다.

#### 해결 방법

새 package `services/core/internal/protosocket`을 만든다. endpoint는 별도 listener가 아니라 기존 HTTP server의 protected chi router 아래에 mount한다. `structpb.Struct`를 semantic envelope wire message로 사용하고, dispatcher가 `action`별 handler registry를 가진다. 아직 task handler는 등록하지 않고 unsupported action을 error envelope로 응답한다.

Before:

```go
// services/core/internal/http/router.go:16
func NewRouter(handler *Handler, logger *slog.Logger, auth AuthConfig) stdhttp.Handler {
	// ...
	protected.Route("/api", func(r chi.Router) {
		r.Post("/integrations/plane/tasks", handler.CreatePlaneTask)
		r.Post("/tasks", handler.CreateTask)
		r.Get("/tasks", handler.ListTasks)
		r.Get("/tasks/{id}", handler.GetTask)
		r.Post("/tasks/{id}/enqueue", handler.EnqueueTask)
	})
	r.Mount("/", protected)
```

After:

```go
func NewRouter(handler *Handler, logger *slog.Logger, auth AuthConfig, protoSocket stdhttp.Handler, protoSocketPath string) stdhttp.Handler {
	// ...
	if protoSocket != nil && protoSocketPath != "" {
		protected.Handle(protoSocketPath, protoSocket)
	}
	protected.Route("/api", func(r chi.Router) {
		// existing REST routes unchanged
	})
	r.Mount("/", protected)
```

New package outline:

```go
package protosocket

import (
	"context"
	"log/slog"
	"sync"

	toki "git.toki-labs.com/toki/proto-socket/go"
	"google.golang.org/protobuf/proto"
	"google.golang.org/protobuf/types/known/structpb"
	"nhooyr.io/websocket"
)

const ProtocolVersion = "nomadcode.proto-socket.v1"

type Config struct {
	HeartbeatIntervalSec int
	HeartbeatWaitSec     int
}

type Envelope struct {
	ProtocolVersion string
	ID              string
	CorrelationID   string
	Type            string
	Channel         string
	Action          string
	Actor           map[string]any
	Auth            map[string]any
	Payload         map[string]any
	Error           *EnvelopeError
	Meta            map[string]any
}

type HandlerFunc func(context.Context, Envelope) Envelope

type Dispatcher struct {
	mu       sync.RWMutex
	handlers map[string]HandlerFunc
}

func ParserMap() toki.ParserMap {
	return toki.ParserMap{
		toki.TypeNameOf(&structpb.Struct{}): func(b []byte) (proto.Message, error) {
			msg := &structpb.Struct{}
			return msg, proto.Unmarshal(b, msg)
		},
	}
}
```

Server outline:

```go
type Server struct {
	cfg        Config
	dispatcher *Dispatcher
	logger     *slog.Logger
	mu         sync.Mutex
	clients    map[*toki.WsClient]struct{}
}

func NewServer(cfg Config, logger *slog.Logger) *Server
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request)
func (s *Server) Close() error
func (s *Server) BroadcastEnvelope(ctx context.Context, env Envelope) error
func (s *Server) Dispatcher() *Dispatcher
```

`ServeHTTP`는 `websocket.Accept`, `toki.NewWsClient(conn, cfg.HeartbeatIntervalSec, cfg.HeartbeatWaitSec, ParserMap())`, `toki.AddRequestListenerTyped[*structpb.Struct,*structpb.Struct]` 순서로 연결한다. `AddRequestListenerTyped`가 error를 반환하면 응답이 없어 timeout이 되므로, validation/action failure는 항상 `type="error"` envelope `*structpb.Struct`를 반환한다.

`main.go` wiring:

```go
protoSocketServer := protosocket.NewServer(protosocket.Config{
	HeartbeatIntervalSec: cfg.ProtoSocketHeartbeatIntervalSec,
	HeartbeatWaitSec:     cfg.ProtoSocketHeartbeatWaitSec,
}, logger)

Handler: apphttp.NewRouter(handler, logger, apphttp.AuthConfig{Username: cfg.AuthUsername, Password: cfg.AuthPassword}, protoSocketServer, cfg.ProtoSocketPath),
```

Shutdown:

```go
if err := server.Shutdown(shutdownCtx); err != nil {
	return err
}
if err := protoSocketServer.Close(); err != nil {
	return err
}
```

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

- [ ] `services/core/internal/protosocket/envelope.go`: envelope struct, error struct, `ToStruct`, `EnvelopeFromStruct`, protocol version defaults를 추가한다.
- [ ] `services/core/internal/protosocket/dispatcher.go`: action registry, unsupported action error envelope, request dispatch를 추가한다.
- [ ] `services/core/internal/protosocket/server.go`: websocket accept, client registry, disconnect cleanup, `Close`, `BroadcastEnvelope`, dispatcher exposure를 추가한다.
- [ ] `services/core/internal/http/router.go`: protected proto-socket route mount를 추가하고 REST routes를 유지한다.
- [ ] `services/core/cmd/server/main.go`: protosocket import, server 생성, router wiring, shutdown close를 추가한다.

#### 테스트 작성

- 작성: `services/core/internal/protosocket/envelope_test.go`
  - `TestEnvelopeStructRoundTrip`: protocol_version/type/channel/action/payload/error/meta round trip.
  - `TestDispatcherReturnsUnsupportedActionErrorEnvelope`: 등록되지 않은 action이 timeout이 아니라 error envelope로 응답되는지 검증.
- 작성: `services/core/internal/protosocket/server_test.go`
  - `TestServerAcceptsWebSocketAndClosesClients`: `httptest.Server` + `websocket.Dial` + `toki.NewWsClient`로 연결하고 `Close()` 후 client disconnect를 검증.
  - `TestServerRespondsWithErrorEnvelopeForUnsupportedAction`: `toki.SendRequestTyped[*structpb.Struct,*structpb.Struct]`로 error envelope 응답을 검증.
- 작성/수정: `services/core/internal/http/middleware_test.go`
  - `TestRouterKeepsHealthzPublicAndProtectsAPI`를 확장하거나 새 테스트를 추가해 `/proto-socket`이 Basic Auth 없이 `401`이고 `/healthz`는 계속 `200`임을 검증한다.

#### 중간 검증

로컬 테스트 실행은 금지다. 허용된 비로컬 테스트 환경에서 실행한다.

```bash
cd services/core && go test -count=1 ./internal/protosocket ./internal/http
```

예상 결과: endpoint, auth route, unsupported action error envelope, websocket lifecycle tests PASS.

### [CORE-ENDPOINT-3] contract note and verification guardrails

#### 문제

`packages/contracts/notes/flutter-core-api-candidates.md:28-84`에는 semantic envelope와 REST compatibility map 후보가 있지만 Core endpoint path가 고정되어 있지 않다. 구현자가 route를 `/`, `/ws`, `/proto-socket` 등으로 갈라버리면 client-rail plan이 흔들린다.

#### 해결 방법

contract note의 proto-socket 원칙 아래에 Core endpoint 후보를 추가한다. source schema가 아니며 `PROTO_SOCKET_PATH` 기본값이 `/proto-socket`임을 명시한다.

After:

```markdown
### 1.4 Core Endpoint 후보
- **기본 경로**: `/proto-socket`
- **인증**: 기존 Core Basic Auth middleware 적용
- **Wire message**: 초기 구현은 `google.protobuf.Struct` semantic envelope를 사용한다.
- **Heartbeat**: proto-socket 기본 heartbeat를 사용하며 Core 설정 기본값은 interval 30s, wait 10s다.
```

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

- [ ] `packages/contracts/notes/flutter-core-api-candidates.md`: endpoint path/auth/wire message/heartbeat 후보를 추가한다.
- [ ] `services/core/internal/protosocket/server_test.go`: heartbeat interval `0` test config를 사용해 test flake를 줄이고, 별도 heartbeat behavior는 final verification에 포함된 package test로 고정한다.

#### 테스트 작성

- 작성: `server_test.go`에서 heartbeat config `0,0`과 non-zero config 생성이 모두 panic 없이 동작하는지 확인한다. 실제 timer wait에 의존하는 sleep 테스트는 작성하지 않는다.

#### 중간 검증

```bash
git diff --check
```

예상 결과: whitespace error 없음.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `services/core/go.mod` | CORE-ENDPOINT-1 |
| `services/core/go.sum` | CORE-ENDPOINT-1 |
| `services/core/internal/config/config.go` | CORE-ENDPOINT-1 |
| `services/core/internal/config/config_test.go` | CORE-ENDPOINT-1 |
| `services/core/internal/protosocket/envelope.go` | CORE-ENDPOINT-2 |
| `services/core/internal/protosocket/dispatcher.go` | CORE-ENDPOINT-2 |
| `services/core/internal/protosocket/server.go` | CORE-ENDPOINT-2 |
| `services/core/internal/protosocket/envelope_test.go` | CORE-ENDPOINT-2 |
| `services/core/internal/protosocket/server_test.go` | CORE-ENDPOINT-2, CORE-ENDPOINT-3 |
| `services/core/internal/http/router.go` | CORE-ENDPOINT-2 |
| `services/core/internal/http/middleware_test.go` | CORE-ENDPOINT-2 |
| `services/core/cmd/server/main.go` | CORE-ENDPOINT-2 |
| `packages/contracts/notes/flutter-core-api-candidates.md` | CORE-ENDPOINT-3 |

## 최종 검증

로컬 테스트 실행은 `agent-test/local/rules.md`에 따라 금지다. 구현 에이전트는 허용된 비로컬 테스트 환경에서 아래 명령을 그대로 실행한다. Go test cache output은 허용하지 않으며 `-count=1`을 유지한다.

```bash
cd services/core && go mod tidy
```

예상 결과: dependency graph 정리 성공, `go.mod`/`go.sum`만 필요한 dependency 변경.

```bash
cd services/core && go test -count=1 ./internal/config ./internal/protosocket ./internal/http
```

예상 결과: config, proto-socket endpoint, auth route tests PASS.

```bash
cd services/core && go test -count=1 ./...
```

예상 결과: Core 전체 package PASS.

```bash
git diff --check
```

예상 결과: whitespace error 없음.

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