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

# Plan - Operation Event Store Review Follow-up

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

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

## 배경

첫 리뷰에서 `OperationEventStore` 구현 방향은 맞지만, 계획상 필수였던 operation-specific ordering index와 Postgres 통합 테스트 assertion이 부족해 `FAIL` 판정을 받았다. 이 follow-up은 production API를 넓히지 않고 migration/test 보완만으로 `event-store` Task를 닫을 수 있게 만든다.

## 사용자 리뷰 요청 흐름

구현 중 차단 조건은 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-ops/rules/project/domain/core/rules.md`
- `agent-ops/rules/project/domain/contracts/rules.md`
- `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`
- `services/core/internal/events/events.go`
- `packages/contracts/notes/gito-control-plane.md`
- `agent-task/m-operation-event-outbox/03+01_operation_event_store/plan_local_G06_0.log`
- `agent-task/m-operation-event-outbox/03+01_operation_event_store/code_review_cloud_G05_0.log`
- `agent-task/archive/2026/06/m-operation-event-outbox/01_operation_store/complete.log`

### 테스트 환경 규칙

- `test_env`: local.
- `agent-test/local/rules.md` 존재, 읽음.
- matched profile: `agent-test/local/core-smoke.md`, 읽음.
- core 변경 최소 검증은 `cd services/core && go test ./...`다.
- storage behavior 변경은 해당 동작 테스트가 필요하다.
- fresh execution이 필요하므로 Go 검증은 `-count=1`을 사용한다.
- Postgres integration test는 `GITO_TEST_DATABASE_URL`이 없으면 SKIP될 수 있지만, env가 있을 때 통과하도록 timestamp precision과 assertion을 맞춰야 한다.

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

- `operation_events` pending index는 테스트되지만, `ListEvents(operationID)`의 `(operation_id, created_at, id)` ordering index는 migration과 snippet test에 없다.
- `TestPostgresOperationEventStoreAppendsListsMarksPublishedAndValidates`는 event count/order/pending/mark 일부만 확인하고, operation id/type/subject/payload/created_at round-trip을 확인하지 않는다.
- 같은 테스트가 Postgres `TIMESTAMPTZ` microsecond precision을 고려하지 않고 `PublishedAt`을 `time.Now().UTC()`와 직접 비교한다.
- invalid input coverage가 empty operation id/event id, array payload, non-positive limit, missing mark id에 그치며 missing type/subject, string/null payload가 빠져 있다.

### 심볼 참조

- renamed/removed symbol: none.
- `OperationEvents()` references: `services/core/internal/storage/postgres.go`, `services/core/internal/storage/storage.go`, `services/core/internal/storage/storage_test.go`, `services/core/internal/controlplane/runtime_test.go`, `services/core/internal/storage/postgres_test.go`.
- `ListPendingEvents` / `MarkPublished` references: `services/core/internal/storage/storage.go`, `services/core/internal/storage/postgres.go`, `services/core/internal/storage/storage_test.go`, `services/core/internal/storage/postgres_test.go`.
- `operation_events` schema references: `services/core/migrations/00001_initial.sql`, `services/core/internal/storage/storage_test.go`, `services/core/internal/storage/postgres.go`, `services/core/internal/storage/postgres_test.go`.

### 분할 판단

- split decision policy evaluated before writing this follow-up.
- 기존 split task directory `03+01_operation_event_store`를 유지한다. follow-up은 같은 storage boundary의 review-required 보완이며 새 sibling으로 나누면 같은 active task의 FAIL 해소 추적이 흩어진다.
- predecessor index `01`은 `agent-task/archive/2026/06/m-operation-event-outbox/01_operation_store/complete.log`로 충족됐다.

### 범위 결정 근거

- Include: `services/core/migrations/00001_initial.sql`, `services/core/internal/storage/storage_test.go`, `services/core/internal/storage/postgres_test.go`.
- Include production `services/core/internal/storage/postgres.go` only if strengthened tests expose an actual implementation defect.
- Exclude: proto-socket event runtime, durable consumer cursor, Redis fanout, roadmap 문서 갱신, contracts note 추가 변경.

### 빌드 등급

- build lane: `local-G06`; bounded storage/migration/test follow-up with deterministic commands.
- review lane: `cloud-G05`; Postgres schema and integration-test skip/precision semantics need careful review.

## 구현 체크리스트

- [ ] `operation_events` migration에 operation-specific ordering index를 추가하고 migration snippet test가 이를 확인한다.
- [ ] `TestPostgresOperationEventStoreAppendsListsMarksPublishedAndValidates`가 timestamp precision, payload/metadata round-trip, invalid type/subject/non-object payload boundary를 검증한다.
- [ ] `git diff --check`, `cd services/core && go test -count=1 ./internal/storage`, focused Postgres test, `cd services/core && go test -count=1 ./...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [REVIEW_API-1] Add Operation Event List Ordering Index

#### 문제

`services/core/internal/storage/postgres.go:938`의 `ListEvents`는 `WHERE operation_id=$1 ORDER BY created_at, id`로 조회한다. 하지만 `services/core/migrations/00001_initial.sql:51`에는 pending partial index만 있어 계획의 "ordering/pending 조회에 필요한 index"가 완성되지 않았다.

Before:

```sql
CREATE INDEX IF NOT EXISTS ix_operation_events_pending_ordering
    ON operation_events (created_at, id)
    WHERE published_at IS NULL;
```

#### 해결 방법

operation별 list query를 위해 `(operation_id, created_at, id)` index를 추가하고, migration snippet test가 해당 index 이름을 요구하게 한다.

After shape:

```sql
CREATE INDEX IF NOT EXISTS ix_operation_events_operation_ordering
    ON operation_events (operation_id, created_at, id);

CREATE INDEX IF NOT EXISTS ix_operation_events_pending_ordering
    ON operation_events (created_at, id)
    WHERE published_at IS NULL;
```

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

- [ ] `services/core/migrations/00001_initial.sql`: `ix_operation_events_operation_ordering` index를 추가한다.
- [ ] `services/core/internal/storage/storage_test.go`: `TestInitialMigrationSupportsStoreBoundary`의 required snippets에 새 index 이름을 추가한다.

#### 테스트 작성

`services/core/internal/storage/storage_test.go`의 기존 migration snippet test를 갱신한다. 새 별도 테스트는 만들지 않는다.

#### 중간 검증

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

Expected: storage package passes.

### [REVIEW_API-2] Strengthen Operation Event Store Integration Assertions

#### 문제

`services/core/internal/storage/postgres_test.go:836`은 Postgres round-trip timestamp를 nanosecond precision의 `time.Now().UTC()`와 직접 비교한다. `TIMESTAMPTZ`는 microsecond precision이므로 DB env가 설정된 runner에서 실패할 수 있다. 또한 `services/core/internal/storage/postgres_test.go:806` 이후의 assertions는 operation id/type/subject/payload/created_at round-trip과 missing type/subject, string/null payload boundary를 확인하지 않는다.

Before:

```go
now := time.Now().UTC()
marked, err := eventStore.MarkPublished(ctx, "evt-001", now)
if err != nil {
	t.Fatalf("mark published: %v", err)
}
if marked.PublishedAt == nil {
	t.Fatal("expected non-nil PublishedAt after mark published")
}
if !marked.PublishedAt.Equal(now) {
	t.Fatalf("expected PublishedAt to be %v, got %v", now, *marked.PublishedAt)
}
```

#### 해결 방법

Seed timestamps and publish timestamp with `Truncate(time.Microsecond)` before writing them, or compare returned timestamps to truncated expected values. Add semantic JSON payload comparison helper inside the test file and assert listed events restore `OperationID`, `Event.ID`, `Event.Type`, `Event.Subject`, `Event.Payload`, `Event.CreatedAt`, and `PublishedAt`. Extend invalid cases for missing type, missing subject, JSON string payload, and JSON null payload. Also assert nil/empty payload normalization to `{}` if a third event is added for that boundary.

After shape:

```go
created1 := time.Now().UTC().Truncate(time.Microsecond)
created2 := created1.Add(time.Second)
now := time.Now().UTC().Truncate(time.Microsecond)

if !listed[0].Event.CreatedAt.Equal(created1) {
	t.Fatalf("created_at round trip: got %v want %v", listed[0].Event.CreatedAt, created1)
}
assertJSONPayload(t, listed[0].Event.Payload, `{"state":"running"}`)
```

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

- [ ] `services/core/internal/storage/postgres_test.go`: seeded `CreatedAt` and publish `now` values use microsecond precision.
- [ ] `services/core/internal/storage/postgres_test.go`: `ListEvents` assertions cover operation id, event id/type/subject, JSON payload, created_at, and nil `PublishedAt` before mark.
- [ ] `services/core/internal/storage/postgres_test.go`: `MarkPublished` assertion compares against microsecond-truncated expected value.
- [ ] `services/core/internal/storage/postgres_test.go`: invalid cases cover empty type, empty subject, JSON string payload, and JSON null payload.
- [ ] `services/core/internal/storage/postgres_test.go`: if helper imports are needed, add only standard-library imports such as `encoding/json` and `reflect`.

#### 테스트 작성

Extend `TestPostgresOperationEventStoreAppendsListsMarksPublishedAndValidates`; do not create a separate test unless the helper makes the existing test too large to read.

#### 중간 검증

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

Expected: PASS with `GITO_TEST_DATABASE_URL`; SKIP is acceptable only when the env var is absent and the exact skip output is recorded.

## 의존 관계 및 구현 순서

Apply `REVIEW_API-1` before `REVIEW_API-2` so the focused integration test and migration snippet check reflect the final schema. Predecessor split index `01` is already satisfied by `agent-task/archive/2026/06/m-operation-event-outbox/01_operation_store/complete.log`.

## 수정 파일 요약

| File | Items |
| --- | --- |
| `services/core/migrations/00001_initial.sql` | REVIEW_API-1 |
| `services/core/internal/storage/storage_test.go` | REVIEW_API-1 |
| `services/core/internal/storage/postgres_test.go` | REVIEW_API-2 |
| `services/core/internal/storage/postgres.go` | REVIEW_API-2 only if strengthened tests expose a production defect |

## 최종 검증

```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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
