<!-- task=m-operation-event-outbox/03+01_operation_event_store plan=0 tag=API -->

# Plan - Operation Event Store

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

`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채우는 것이 구현의 필수 마지막 단계다. 구현 후 검증 명령을 실행하고 실제 출력, 계획 대비 변경, 주요 설계 결정을 기록한 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 범위 충돌 없이는 안전하게 진행할 수 없으면 review stub의 `사용자 리뷰 요청`에 근거를 기록하고 중단한다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 말고, `USER_REVIEW.md`, `complete.log`, archive 이동은 만들지 않는다. 후속 에이전트가 명령 재실행이나 증거 수집으로 해소할 수 있는 검증 공백은 사용자 리뷰 요청이 아니다.

## 배경

현재 Operation Event Outbox Milestone의 outbox Epic에서 `event-model`과 `protosocket-events`는 바로 정리되었고, 남은 큰 항목은 durable `operation_events` store 구현이다. Migration에는 `operation_events` table이 있지만 `PgStore.OperationEvents()`는 아직 `nil`을 반환하고, publish 완료 상태도 schema/interface에 없다. 이 plan은 `OperationEventStore`를 append/list/mark-published boundary로 확장해 Postgres 구현과 검증을 만든다.

## 사용자 리뷰 요청 흐름

구현 중 차단 조건은 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 해당 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식을 따른다. 구현 에이전트는 직접 사용자에게 묻지 않고, code-review가 사용자 리뷰 필요성을 검증하고 `USER_REVIEW.md` 작성 여부를 결정한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/control-plane-foundation/milestones/operation-event-outbox.md`
- Task ids:
  - `event-store`: event append/list/mark-published boundary를 만든다.
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `agent-roadmap/current.md`
- `agent-roadmap/phase/control-plane-foundation/PHASE.md`
- `agent-roadmap/phase/control-plane-foundation/milestones/operation-event-outbox.md`
- `agent-test/local/rules.md`
- `agent-test/local/core-smoke.md`
- `agent-test/local/contracts-smoke.md`
- `services/core/go.mod`
- `services/core/internal/events/events.go`
- `services/core/internal/core/model.go`
- `services/core/internal/storage/storage.go`
- `services/core/internal/storage/postgres.go`
- `services/core/migrations/00001_initial.sql`
- `services/core/internal/storage/storage_test.go`
- `services/core/internal/storage/postgres_test.go`
- `services/core/internal/controlplane/runtime_test.go`
- `packages/contracts/notes/gito-control-plane.md`
- `agent-task/archive/2026/06/m-operation-event-outbox/01_operation_store/complete.log`

### 테스트 환경 규칙

- `test_env`: local.
- `agent-test/local/rules.md` 존재, 읽음. core 변경은 `agent-test/local/core-smoke.md`로 라우팅된다.
- 적용 명령: core 코드 변경 최소 `cd services/core && go test ./...`; storage behavior 변경은 해당 동작 테스트 또는 차단 사유 필요.
- `contracts-smoke`도 읽었지만 이 plan은 contract 파일을 추가로 수정하지 않는다. 작은 contract 정리는 이미 별도 직접 변경으로 처리되었다.
- Postgres integration tests는 기존 `GITO_TEST_DATABASE_URL` guard가 없으면 SKIP된다. 구현 에이전트는 skip을 실제 출력으로 남기고, 가능한 경우 같은 명령을 DB env가 설정된 runner에서 실행한다.

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

- `operation_events` table 존재 여부는 `storage_test.go` migration snippet test가 확인한다.
- `PgStore.OperationEvents()` non-nil, append/list, payload JSONB round-trip, `(created_at, id)` ordering은 아직 테스트가 없다.
- `mark-published` 상태는 현재 schema/interface에 없다. 이번 plan에서 `published_at`과 pending list/mark published API를 함께 추가해야 `event-store` Task를 PASS 때 체크할 수 있다.

### 심볼 참조

- renamed/removed symbol: none.
- `OperationEvents()` references: `services/core/internal/storage/postgres.go:64`, `services/core/internal/storage/storage.go:18`, `services/core/internal/storage/storage.go:166`, `services/core/internal/storage/storage_test.go:155`, `services/core/internal/controlplane/runtime_test.go:43`.
- `OperationEventStore` references: `services/core/internal/storage/storage.go:80`, `services/core/internal/storage/storage_test.go:86`.
- `operation_events` schema references: `services/core/migrations/00001_initial.sql:41`, `services/core/internal/storage/storage_test.go:190`.

### 분할 판단

- split decision policy evaluated before file creation.
- shared task group: `agent-task/m-operation-event-outbox/`.
- this subtask: `03+01_operation_event_store`, depends on predecessor index `01`.
- predecessor `01_operation_store` satisfied by archived `agent-task/archive/2026/06/m-operation-event-outbox/01_operation_store/complete.log`.
- sibling `02+01_operation_pick` is complete but not a dependency for this implementation.
- 내부 추가 split은 하지 않는다. schema/interface/Postgres/test가 함께 움직여야 하는 하나의 storage boundary이며, 나누면 중간 상태가 compile되지 않는다.

### 범위 결정 근거

- Include: `services/core/internal/storage/storage.go`, `services/core/internal/storage/postgres.go`, `services/core/migrations/00001_initial.sql`, `services/core/internal/storage/storage_test.go`, `services/core/internal/storage/postgres_test.go`, and fake store compile updates in `services/core/internal/controlplane/runtime_test.go`.
- Include migration shape change for `published_at`, because `mark-published` is part of the roadmap Task.
- Exclude: proto-socket `event.ack`, durable consumer cursor, Redis fanout, runtime auto-append, and provider branch event persistence. Those require separate routing beyond the current `OperationEventStore` boundary.

### 빌드 등급

- build lane: `local-G06`; bounded storage implementation with deterministic unit/integration tests, but includes Postgres JSONB and FK behavior.
- review lane: `cloud-G05`; storage boundary and integration-test skip semantics need careful review.

## 구현 체크리스트

- [ ] `PgStore`가 `pgOperationEventStore`를 초기화하고 `OperationEvents()`에서 반환한다.
- [ ] `OperationEventStore`가 append/list/pending-list/mark-published boundary를 표현하도록 interface와 fakes를 갱신한다.
- [ ] migration이 `published_at`을 저장하고 `operation_events` ordering/pending 조회에 필요한 index를 제공한다.
- [ ] `AppendEvent`가 operation id, event id/type/subject, JSON object payload를 검증한 뒤 `operation_events`에 저장한다.
- [ ] `ListEvents`, `ListPendingEvents`, `MarkPublished`가 payload와 publish 상태를 복원한다. 검증: event order와 retry 가능한 publish 상태를 확인한다.
- [ ] `TestPostgresOperationEventStoreAppendsListsMarksPublishedAndValidates`를 추가해 non-nil store, append/list round-trip, pending list, mark-published, invalid input, ordering을 검증한다.
- [ ] `cd services/core && go test -count=1 ./internal/storage`와 `cd services/core && go test -count=1 ./...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [API-1] Wire Postgres OperationEventStore

#### 문제

[postgres.go](/config/workspace/gito/services/core/internal/storage/postgres.go:19)의 `PgStore`는 repos/leases/ops/watches/cursors/deliveries만 보유하고, [postgres.go](/config/workspace/gito/services/core/internal/storage/postgres.go:64)의 `OperationEvents()`는 `nil`을 반환한다. 반면 [storage.go](/config/workspace/gito/services/core/internal/storage/storage.go:80)는 operation event store interface를 이미 제공하지만 `mark-published` boundary가 없다.

Before:

```go
// services/core/internal/storage/postgres.go:19
type PgStore struct {
	pool       *pgxpool.Pool
	repos      *pgRepoStore
	leases     *pgWorkspaceLeaseStore
	ops        *pgOperationStore
	watches    *pgBranchWatchStore
	cursors    *pgRevisionCursorStore
	deliveries *pgProviderDeliveryStore
}

// services/core/internal/storage/postgres.go:61
func (s *PgStore) Repos() RepoStore                          { return s.repos }
func (s *PgStore) WorkspaceLeases() WorkspaceLeaseStore      { return s.leases }
func (s *PgStore) Operations() OperationStore                { return s.ops }
func (s *PgStore) OperationEvents() OperationEventStore      { return nil }
func (s *PgStore) BranchWatches() BranchWatchStore           { return s.watches }
func (s *PgStore) RevisionCursors() RevisionCursorStore      { return s.cursors }
func (s *PgStore) ProviderDeliveries() ProviderDeliveryStore { return s.deliveries }
```

#### 해결 방법

`storage.OperationEvent`에 `PublishedAt *time.Time`을 추가하고 `OperationEventStore`에 `ListPendingEvents(ctx, limit int)`와 `MarkPublished(ctx, eventID string, now time.Time)`를 추가한다. `PgStore`에 `opEvents *pgOperationEventStore`를 추가하고 `NewPgStore`에서 초기화한다. `encoding/json`과 `events` import를 추가한다. `pgOperationEventStore`는 `operation_events` table을 사용한다.

After shape:

```go
type PgStore struct {
	pool       *pgxpool.Pool
	repos      *pgRepoStore
	leases     *pgWorkspaceLeaseStore
	ops        *pgOperationStore
	opEvents   *pgOperationEventStore
	watches    *pgBranchWatchStore
	cursors    *pgRevisionCursorStore
	deliveries *pgProviderDeliveryStore
}

s.opEvents = &pgOperationEventStore{pool: pool}

func (s *PgStore) OperationEvents() OperationEventStore { return s.opEvents }
```

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

- [ ] `services/core/internal/storage/postgres.go`: imports에 `encoding/json`과 `events`를 추가한다.
- [ ] `services/core/internal/storage/storage.go`: `OperationEvent.PublishedAt`, `ListPendingEvents`, `MarkPublished`를 추가한다.
- [ ] `services/core/internal/storage/postgres.go`: `PgStore` struct와 `NewPgStore` 초기화에 `opEvents`를 추가한다.
- [ ] `services/core/internal/storage/postgres.go`: `OperationEvents()`가 `s.opEvents`를 반환하게 한다.
- [ ] `services/core/internal/storage/postgres.go`: `pgOperationEventStore`, `normalizeOperationEvent`, `scanOperationEvent` helper를 추가한다.
- [ ] `services/core/internal/storage/storage_test.go`와 `services/core/internal/controlplane/runtime_test.go`: fake `OperationEventStore` 구현을 새 interface에 맞춘다.

#### 테스트 작성

`services/core/internal/storage/postgres_test.go`에 `TestPostgresOperationEventStoreAppendsListsMarksPublishedAndValidates`를 추가한다.

#### 중간 검증

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

Expected: storage package passes; if `GITO_TEST_DATABASE_URL` is absent, Postgres integration tests print existing SKIP reasons.

### [API-2] Persist, List, and Mark Published Operation Events

#### 문제

[00001_initial.sql](/config/workspace/gito/services/core/migrations/00001_initial.sql:41)는 `operation_events` table을 만들지만 publish 상태 컬럼이 없다. [storage.go](/config/workspace/gito/services/core/internal/storage/storage.go:75)의 `OperationEvent`는 `events.Event` payload를 들고 있어 JSONB round-trip, ordering, pending retry 상태를 구현해야 한다.

Before:

```go
// services/core/internal/storage/storage.go:75
type OperationEvent struct {
	OperationID string
	Event       events.Event
}

type OperationEventStore interface {
	AppendEvent(ctx context.Context, ev OperationEvent) error
	ListEvents(ctx context.Context, operationID string) ([]OperationEvent, error)
}
```

#### 해결 방법

`AppendEvent` validates:

- `OperationID`, `Event.ID`, `Event.Type`, `Event.Subject` are non-empty.
- nil/empty payload becomes `{}`.
- non-empty payload must be valid JSON object, not array/string/null.
- `CreatedAt` zero becomes `time.Now().UTC()`, otherwise UTC normalized.
- `PublishedAt` must be nil on append; publish completion is controlled by `MarkPublished`.

SQL:

```sql
INSERT INTO operation_events (id, operation_id, type, subject, payload, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
```

`ListEvents` trims `operationID`, rejects empty input with `ErrInvalidOperation`, and runs:

```sql
SELECT operation_id, id, type, subject, payload, created_at, published_at
FROM operation_events
WHERE operation_id=$1
ORDER BY created_at, id
```

`ListPendingEvents` rejects non-positive limits and returns unpublished rows:

```sql
SELECT operation_id, id, type, subject, payload, created_at, published_at
FROM operation_events
WHERE published_at IS NULL
ORDER BY created_at, id
LIMIT $1
```

`MarkPublished` sets `published_at` and returns the marked event:

```sql
UPDATE operation_events
SET published_at=$2
WHERE id=$1
RETURNING operation_id, id, type, subject, payload, created_at, published_at
```

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

- [ ] `services/core/migrations/00001_initial.sql`: `published_at TIMESTAMPTZ`와 pending/order index를 추가한다.
- [ ] `services/core/internal/storage/storage_test.go`: migration snippet test가 `published_at  TIMESTAMPTZ`와 index를 확인하게 한다.
- [ ] `services/core/internal/storage/postgres.go`: append SQL을 추가하고 FK/unique errors are wrapped as `append operation event`.
- [ ] `services/core/internal/storage/postgres.go`: list/pending/mark-published SQL을 추가하고 rows scanning errors are wrapped as `scan operation event`.
- [ ] `services/core/internal/storage/postgres_test.go`: repo와 operation을 seed한 뒤 two events를 append한다.
- [ ] `services/core/internal/storage/postgres_test.go`: listed events의 order, operation id, type, subject, payload bytes, created_at을 assert한다.
- [ ] `services/core/internal/storage/postgres_test.go`: pending list가 unpublished events만 반환하고 `MarkPublished` 뒤에는 해당 event가 빠지는지 assert한다.
- [ ] `services/core/internal/storage/postgres_test.go`: empty operation id, missing event id/type/subject, invalid JSON array/string payload, non-positive pending limit, missing mark id return `ErrInvalidOperation` or `ErrOperationNotFound` as appropriate.

#### 테스트 작성

Write `TestPostgresOperationEventStoreAppendsListsMarksPublishedAndValidates` in `services/core/internal/storage/postgres_test.go`. Assertions:

- `store.OperationEvents()` is non-nil.
- append two events with payloads `{"state":"running"}` and `{"state":"succeeded"}`.
- list returns two events ordered by `(created_at, id)`.
- pending list returns both before publish, then only the second after `MarkPublished(first.ID, now)`.
- marked event has non-nil `PublishedAt`.
- invalid payload `[]byte("[1]")` is rejected.
- invalid empty fields are rejected.

#### 중간 검증

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

Expected: PASS when `GITO_TEST_DATABASE_URL` is set; existing SKIP is acceptable only when the env var is absent and must be recorded verbatim.

## 의존 관계 및 구현 순서

The directory name `03+01_operation_event_store` means predecessor index `01` must be complete before implementation. This is satisfied by `agent-task/archive/2026/06/m-operation-event-outbox/01_operation_store/complete.log`. Implement API-1 before API-2 because tests need the concrete store to be wired.

## 수정 파일 요약

| File | Items |
| --- | --- |
| `services/core/internal/storage/storage.go` | API-1 |
| `services/core/internal/storage/postgres.go` | API-1, API-2 |
| `services/core/migrations/00001_initial.sql` | API-2 |
| `services/core/internal/storage/storage_test.go` | API-1, API-2 |
| `services/core/internal/storage/postgres_test.go` | API-1, API-2 |
| `services/core/internal/controlplane/runtime_test.go` | API-1 |

## 최종 검증

```bash
git diff --check
cd services/core && go test -count=1 ./internal/storage
cd services/core && go test -count=1 -run TestPostgresOperationEventStoreAppendsListsMarksPublishedAndValidates -v ./internal/storage
cd services/core && go test -count=1 ./...
```

Expected: `git diff --check` has no output. Go tests pass; the focused Postgres test may SKIP only when `GITO_TEST_DATABASE_URL` is unset, and that skip must be pasted into the review stub.

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