<!-- task=m-forgejo-branch-event-mvp/02+01_watch_store plan=0 tag=API -->

# Plan - API

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

구현이 끝났다고 판단하기 전에 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 설계 결정, 검증 출력으로 채운다. 검증 명령을 실행하고 출력 원문을 붙인 뒤 active 파일을 그대로 둔 채 리뷰 준비를 보고한다. 최종 판정, log archive, `complete.log`, task directory archive 이동은 code-review-skill 전용이다.

구현 중 사용자만 결정할 수 있는 범위 변경, 사용자 소유 외부 환경/secret/service 준비, 또는 계획 충돌이 발생하면 active review stub의 `사용자 리뷰 요청` 섹션에 정확한 증거를 채우고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백은 사용자 리뷰 요청이 아니라 검증 결과/계획 대비 변경 사항에 기록한다.

## 배경

현재 Forgejo branch event MVP는 branch watch와 event record를 `Runtime` 메모리에만 둔다. `services/core/migrations/00001_initial.sql`에는 `revision_cursors`가 이미 있으나, branch watch table과 store contract가 없고 duplicate delivery를 suppress할 durable key도 없다. `../nomadcode` 소비 smoke를 먼저 PASS시킨 뒤, 이 작업은 watched branch와 revision cursor를 PostgreSQL source of truth로 옮기고 duplicate push를 재전송해도 event/broadcast가 한 번만 발생하도록 hardening한다.

## 사용자 리뷰 요청 흐름

구현 중 차단 사유는 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/control-plane-foundation/milestones/forgejo-branch-event-mvp.md`
- Task ids:
  - `watch-store`: branch watch와 revision cursor를 Postgres store로 옮긴다.
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `AGENTS.md`
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/private/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/rules/project/domain/core/rules.md`
- `agent-ops/rules/project/domain/contracts/rules.md`
- `agent-ops/rules/project/domain/workspace-ops/rules.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-test/local/rules.md`
- `agent-test/local/core-smoke.md`
- `agent-test/local/contracts-smoke.md`
- `agent-test/local/workspace-ops-smoke.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/control-plane-foundation/milestones/forgejo-branch-event-mvp.md`
- `agent-contract/index.md`
- `agent-contract/provided/gito-forgejo-branch-events-v1.md`
- `services/core/go.mod`
- `services/core/internal/config/config.go`
- `services/core/internal/config/config_test.go`
- `services/core/cmd/server/main.go`
- `services/core/internal/core/model.go`
- `services/core/internal/events/events.go`
- `services/core/internal/storage/storage.go`
- `services/core/internal/storage/storage_test.go`
- `services/core/migrations/00001_initial.sql`
- `services/core/internal/controlplane/runtime.go`
- `services/core/internal/controlplane/router.go`
- `services/core/internal/controlplane/router_test.go`
- `services/core/internal/provider/forgejo/push.go`
- `services/core/internal/provider/forgejo/push_test.go`
- `services/core/internal/protosocket/envelope.go`
- `services/core/internal/protosocket/subscription.go`
- `services/core/internal/protosocket/subscription_test.go`

### 테스트 환경 규칙

- `test_env=local`.
- `agent-test/local/rules.md`가 존재하며 전체를 읽었다. local 기본은 standard remote runner이고, 현재 checkout의 `git diff --check`는 편집 직후 preflight로만 사용한다.
- 매칭 profile은 `agent-test/local/core-smoke.md`, `agent-test/local/contracts-smoke.md`, `agent-test/local/workspace-ops-smoke.md`다.
- core 필수 검증은 `cd services/core && go test ./...`이고, storage/provider/proto-socket behavior 변경에는 해당 테스트 또는 차단 사유가 필요하다.
- contracts/docs 변경은 `git diff --check`와 contract pointer 일관성 확인이 필요하다.
- 이 계획은 Postgres persistence를 검증해야 하므로 `15460` host port를 쓰는 disposable PostgreSQL container를 최종 검증에 포함한다. Docker가 없으면 `command -v docker` 실패 출력과 함께 사용자 리뷰 요청이 아니라 검증 차단 사유로 기록한다.
- Go test cache는 persistence/idempotency 확인에 부적합하므로 최종 Go 테스트는 `-count=1`을 사용한다.

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

- `Runtime.RegisterBranchWatch`/`ListBranchWatches`는 현재 in-memory map만 검증한다. restart 이후 같은 store에서 watch가 유지되는 테스트가 없다.
- `Runtime.HandleRevision`은 watched branch event 생성만 검증한다. duplicate `X-Forgejo-Delivery` 또는 revision tuple 재전송을 idempotent하게 처리하는 테스트가 없다.
- `storage`는 boundary interface와 migration snippet만 검증한다. branch watch, revision cursor, delivery idempotency store method와 PostgreSQL round-trip 테스트가 없다.
- `/callbacks/forgejo/push` 응답은 duplicate 여부를 표현하지 않는다. REST response field 추가는 additive로 다루고 contract note에 반영한다.

### 심볼 참조

- 이름 변경은 계획하지 않는다.
- `NewRuntime` call site: `services/core/internal/controlplane/router.go:23`, `services/core/internal/controlplane/router_test.go:185`, `services/core/internal/controlplane/router_test.go:220`.
- `RegisterBranchWatch` call site: `services/core/internal/controlplane/router.go:129`.
- `ListBranchWatches` call site: `services/core/internal/controlplane/router.go:122`.
- `HandleRevision` call site: `services/core/internal/controlplane/router.go:184`.
- `storage.New` call site: `services/core/internal/storage/storage_test.go:64`, `services/core/internal/storage/storage_test.go:73`, `services/core/internal/storage/storage_test.go:102`.

### 분할 판단

split decision policy를 계획 파일 선택 전에 평가했다. 공유 task group은 `m-forgejo-branch-event-mvp`이며 sibling은 다음과 같다.

- `01_consumer_smoke`: NomadCode consumer smoke/fixture. predecessor 없음. 1차 MVP 검증선이다.
- `02+01_watch_store`: Gito core storage/migration/runtime/idempotency. product sequencing상 `01_consumer_smoke` PASS 이후 진행한다.

storage/migration과 외부 consumer smoke는 ownership, test strategy, risk profile이 달라 분리한다.

## 의존 관계 및 구현 순서

`02+01_watch_store`는 같은 task group의 `01_consumer_smoke`가 `complete.log`를 만든 뒤 구현한다. 현재 predecessor `agent-task/m-forgejo-branch-event-mvp/01_consumer_smoke/complete.log`는 아직 없으므로, 이 계획은 hardening backlog로 유지하고 먼저 `01_consumer_smoke`를 진행한다.

### 범위 결정 근거

- persistent event outbox 전체 구현은 Milestone 범위 제외이므로 추가하지 않는다. Duplicate suppression에 필요한 delivery ledger만 둔다.
- Redis fanout, PR/MR provider API, NomadCode Plane mutation은 범위 제외다.
- `../nomadcode` consumer 구현은 predecessor plan `01_consumer_smoke`가 소유한다.
- 기존 REST callback과 proto-socket event payload는 유지하고, duplicate 상태 표시는 REST callback response에 additive field로만 추가한다.

### 빌드 등급

build lane은 `cloud-G07`, review lane은 `cloud-G07`이다. storage/migration, idempotency, runtime wiring, protocol response가 함께 움직이고 Postgres integration evidence가 필요해 local context만으로 리뷰하기 어렵다.

## 구현 체크리스트

- [ ] [API-1] branch watch, revision cursor, provider delivery idempotency를 위한 core/storage 계약과 migration을 추가한다.
- [ ] [API-2] `Runtime`이 store-backed watch lookup, cursor update, duplicate suppression을 사용하도록 바꾼다. 검증: restart 이후에도 watch/cursor가 유지되고 duplicate push가 idempotent하게 처리된다.
- [ ] [API-3] router/server wiring과 REST/contract surface를 additive로 갱신한다.
- [ ] [API-4] Postgres-backed integration test와 focused Go tests를 실행해 persistence/idempotency evidence를 남긴다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [API-1] Store Contract And Migration

#### 문제

`storage.Store`는 `services/core/internal/storage/storage.go:11-17`에서 repos/leases/operations/events만 노출한다. `services/core/migrations/00001_initial.sql:50-56`에는 `revision_cursors`만 있고 branch watch와 provider delivery ledger가 없다.

Before (`services/core/internal/storage/storage.go:11-17`):

```go
type Store interface {
	Ping(ctx context.Context) error
	Repos() RepoStore
	WorkspaceLeases() WorkspaceLeaseStore
	Operations() OperationStore
	OperationEvents() OperationEventStore
}
```

#### 해결 방법

`core` 또는 `storage` boundary에 `BranchWatch`, `RevisionCursor`, `ProviderDelivery` 모델을 추가하고, `storage.Store`에 branch event 관련 sub-store를 추가한다. PostgreSQL schema는 `branch_watches`, `revision_cursors`, `provider_deliveries`를 갖게 한다.

After 방향:

```go
type Store interface {
	Ping(ctx context.Context) error
	Repos() RepoStore
	WorkspaceLeases() WorkspaceLeaseStore
	Operations() OperationStore
	OperationEvents() OperationEventStore
	BranchWatches() BranchWatchStore
	RevisionCursors() RevisionCursorStore
	ProviderDeliveries() ProviderDeliveryStore
}
```

`branch_watches`는 `id`, `provider`, `repo_id`, `branch`, `created_at`을 갖고 `UNIQUE(provider, repo_id, branch)`를 둔다. `provider_deliveries`는 raw payload 없이 `provider`, `delivery_id`, `dedupe_key`, `event_id`, `repo_id`, `branch`, `revision`, `created_at`을 저장하고 `UNIQUE(provider, dedupe_key)`로 duplicate push를 판정한다.

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

- [ ] `services/core/internal/core/model.go`에 branch watch/cursor/delivery 모델을 추가한다.
- [ ] `services/core/internal/storage/storage.go`에 store interfaces와 `Boundary` getter/injection을 추가한다.
- [ ] `services/core/internal/storage/storage_test.go` fake store와 compile-time checks를 갱신한다.
- [ ] `services/core/migrations/00001_initial.sql`에 `branch_watches`, `provider_deliveries`, 필요한 index/drop 순서를 추가한다.
- [ ] raw secret이나 provider payload 원문을 migration/test fixture에 넣지 않는다.

#### 테스트 작성

`services/core/internal/storage/storage_test.go`에 migration snippet 검증을 확장한다. `branch_watches`, `revision_cursors`, `provider_deliveries`, `UNIQUE(provider, repo_id, branch)`, `UNIQUE(provider, dedupe_key)`를 확인한다.

#### 중간 검증

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

기대 결과: storage contract와 migration snippet test가 PASS한다.

### [API-2] Store-Backed Runtime

#### 문제

`Runtime`은 `services/core/internal/controlplane/runtime.go:22-26`에서 `watches map`과 `records []EventRecord`만 가진다. `HandleRevision`은 `services/core/internal/controlplane/runtime.go:102-121`에서 match 후 매번 record를 append하므로 restart persistence와 duplicate suppression이 없다.

Before (`services/core/internal/controlplane/runtime.go:102-121`):

```go
r.mu.Lock()
matched := r.matchesLocked(provider, revision)
if matched {
	r.records = append(r.records, record)
}
r.mu.Unlock()
```

#### 해결 방법

`Runtime`에 optional durable store를 주입한다. store가 있으면 branch match는 store의 `FindBranchWatch`/`ListBranchWatches`를 사용하고, matched first delivery만 `ProviderDeliveries().RecordOnce`와 `RevisionCursors().Upsert`를 실행한다. store가 없으면 기존 in-memory path를 유지해 existing unit tests와 no-DB local smoke를 깨지 않는다.

After 방향:

```go
result, err := r.HandleRevision(ctx, "forgejo", deliveryID, revision)
if result.Duplicate {
	return result.Record, true, nil
}
```

dedupe key는 `delivery_id`가 있으면 `delivery:<delivery_id>`, 없으면 `revision:<repo_id>:<branch>:<before>:<after>`로 만든다. Duplicate이면 두 번째 event append/broadcast/cursor update를 하지 않고, response에는 additive `duplicate: true`를 싣는다.

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

- [ ] `services/core/internal/controlplane/runtime.go`에 durable store option과 `HandleRevisionResult`를 추가한다.
- [ ] nil store fallback은 기존 behavior와 API response를 유지한다.
- [ ] duplicate push는 event record와 proto-socket broadcast가 한 번만 발생하도록 한다.
- [ ] revision cursor는 matched non-duplicate push에서 `after` revision으로 upsert한다.
- [ ] store error는 `500 failed to publish event`로 숨기지 말고 내부 error로 반환하되 raw payload/secret을 로그나 response에 쓰지 않는다.

#### 테스트 작성

`services/core/internal/controlplane/router_test.go` 또는 새 runtime test에 shared fake durable store를 추가한다. Runtime A가 branch watch를 등록한 뒤 Runtime B가 같은 store로 push를 처리해 matched event를 만드는 "restart" test와, 같은 delivery id를 두 번 처리해 broadcast/event count가 1인 duplicate test를 작성한다.

#### 중간 검증

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

기대 결과: restart persistence와 duplicate idempotency focused tests가 PASS한다.

### [API-3] Router Server Wiring And Contract

#### 문제

`NewRouter`는 `services/core/internal/controlplane/router.go:17-24`에서 항상 in-memory `Runtime`을 만든다. `handleForgejoPush`는 `services/core/internal/controlplane/router.go:184-193`에서 duplicate 여부를 응답하지 않는다. `services/core/cmd/server/main.go`는 `DATABASE_URL`을 읽지만 router에 store를 주입하지 않는다.

Before (`services/core/internal/controlplane/router.go:17-24`):

```go
func NewRouter(cfg config.Config, logger *slog.Logger) http.Handler {
	mux := http.NewServeMux()
	protoServer := protosocket.NewServer(protosocket.Config{
		HeartbeatIntervalSec: cfg.ProtoSocketHeartbeatSec,
		HeartbeatWaitSec:     cfg.ProtoSocketHeartbeatWait,
	}, logger)
	runtime := NewRuntime(protoServer)
```

#### 해결 방법

router construction을 option 기반으로 분리해 tests가 fake runtime/store를 주입할 수 있게 한다. server는 `DATABASE_URL`이 있으면 pgxpool 기반 branch event store를 만들고 runtime에 주입하며, 없으면 in-memory fallback을 유지한다. REST response는 duplicate 처리 시 `duplicate: true`를 additive field로 제공하고, contract 문서에 optional field와 idempotency semantics를 짧게 추가한다.

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

- [ ] `services/core/go.mod`에 `github.com/jackc/pgx/v5` 계열 dependency를 추가한다.
- [ ] `services/core/cmd/server/main.go`에서 `DATABASE_URL`이 있을 때 Postgres store를 초기화하고 shutdown 시 pool을 닫는다.
- [ ] `services/core/internal/controlplane/router.go`에 runtime/store injection path를 추가한다.
- [ ] `services/core/internal/controlplane/router_test.go`에 duplicate response field를 확인하는 test를 추가한다.
- [ ] `agent-contract/provided/gito-forgejo-branch-events-v1.md`에 duplicate response field와 idempotency 기준을 additive로 문서화한다.

#### 테스트 작성

router test는 `TestForgejoPushDuplicateDeliveryIsIdempotent`를 추가해 두 번째 POST가 `accepted=true`, `matched=true`, `duplicate=true`이며 `/api/events` count가 증가하지 않는지 확인한다. contract docs는 `git diff --check`로 검증한다.

#### 중간 검증

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

기대 결과: controlplane/provider/protosocket focused tests가 PASS한다.

### [API-4] Postgres Integration Evidence

#### 문제

현재 저장소에는 실제 PostgreSQL store 구현이 없고 `services/core/internal/storage/storage_test.go:112-134`는 migration text snippet만 검사한다. Roadmap target의 restart persistence는 fake store만으로 완료 근거가 부족하다.

#### 해결 방법

`services/core/internal/storage`에 pgxpool 기반 implementation과 integration test를 추가한다. Integration test는 `GITO_TEST_DATABASE_URL`이 설정된 경우 migration을 적용하고, branch watch upsert/list/find, revision cursor upsert/get, delivery record-once duplicate behavior를 검증한다. Shell final verification은 disposable PostgreSQL container를 `15460`에 띄워 env를 제공한다.

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

- [ ] `services/core/internal/storage/postgres.go` 또는 동등한 파일에 pgxpool store implementation을 추가한다.
- [ ] `services/core/internal/storage/postgres_test.go`에 `GITO_TEST_DATABASE_URL` 기반 integration test를 추가한다.
- [ ] test setup은 migration 파일을 재사용하고 raw secret/provider payload를 저장하지 않는다.
- [ ] integration test는 같은 database를 재사용해도 deterministic하도록 schema cleanup 또는 unique test ids를 사용한다.

#### 테스트 작성

`TestPostgresBranchEventStorePersistsWatchCursorAndDedupesDelivery`를 작성한다. 한 store instance가 watch/cursor/delivery를 기록하고, 새 store instance가 같은 DB에서 watch/cursor를 읽으며 같은 dedupe key의 두 번째 record가 duplicate로 판정되는지 검증한다.

#### 중간 검증

```bash
cd services/core && GITO_TEST_DATABASE_URL='postgres://postgres:postgres@127.0.0.1:15460/gito_test?sslmode=disable' go test -count=1 ./internal/storage
```

기대 결과: Postgres integration test가 PASS한다.

## 수정 파일 요약

| 파일 | 항목 |
| --- | --- |
| `services/core/internal/core/model.go` | API-1 |
| `services/core/internal/storage/storage.go` | API-1 |
| `services/core/internal/storage/storage_test.go` | API-1 |
| `services/core/migrations/00001_initial.sql` | API-1 |
| `services/core/internal/controlplane/runtime.go` | API-2 |
| `services/core/internal/controlplane/router.go` | API-2, API-3 |
| `services/core/internal/controlplane/router_test.go` | API-2, API-3 |
| `services/core/cmd/server/main.go` | API-3 |
| `services/core/go.mod` | API-3, API-4 |
| `services/core/go.sum` | API-3, API-4 |
| `services/core/internal/storage/postgres.go` | API-4 |
| `services/core/internal/storage/postgres_test.go` | API-4 |
| `agent-contract/provided/gito-forgejo-branch-events-v1.md` | API-3 |

## 최종 검증

```bash
command -v docker
docker rm -f gito-watch-store-test >/dev/null 2>&1 || true
docker run --name gito-watch-store-test -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=gito_test -p 15460:5432 -d postgres:16
for i in $(seq 1 30); do docker exec gito-watch-store-test pg_isready -U postgres && break; sleep 1; done
cd services/core && GITO_TEST_DATABASE_URL='postgres://postgres:postgres@127.0.0.1:15460/gito_test?sslmode=disable' go test -count=1 ./internal/storage ./internal/controlplane ./internal/provider/forgejo ./internal/protosocket
cd services/core && go test -count=1 ./...
cd services/core && go vet ./...
git diff --check
docker rm -f gito-watch-store-test
```

기대 결과: Docker availability check PASS, disposable PostgreSQL-backed focused tests PASS, full Go test PASS, go vet PASS, diff whitespace check PASS. 모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
