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

# Operation Store Duplicate Conflict Fix

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

`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채워야 구현이 끝난다. 검증 명령을 실행하고 출력 원문을 붙인 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 종결, log rename, `complete.log`, archive 이동은 code-review-skill 전용이다. 사용자 결정, 사용자 소유 외부 환경, scope 충돌 없이는 진행할 수 없으면 review stub의 `사용자 리뷰 요청`에 근거를 기록하고 중단한다. 구현 중 사용자에게 직접 묻거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 재실행으로 채울 수 있는 검증 증거 공백은 사용자 리뷰 요청이 아니다.

## 배경

이 후속 계획은 `code_review_cloud_G05_0.log`의 Required 이슈만 해결한다. `CreateOperation`은 idempotency duplicate와 operation id collision을 구분해야 한다. 현재 구현은 unique violation의 constraint를 보지 않고 `op.IdempotencyKey != ""`이면 idempotency duplicate로 간주해, 같은 `id`와 다른 `idempotency_key` 요청에서 잘못된 오류를 반환할 수 있다.

## 사용자 리뷰 요청 흐름

구현 중 차단은 active `CODE_REVIEW-*-G??.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-foundation/milestones/operation-event-outbox.md`
- Task ids:
  - `op-store`: operation create/start/succeed/fail/cancel store boundary를 만든다.
- Completion mode: check-on-pass

## 분석 결과

### 선행 리뷰

- `agent-task/m-operation-event-outbox/01_operation_store/code_review_cloud_G05_0.log`
- 판정: FAIL
- Required: `CreateOperation` unique violation 분기가 PK 충돌과 idempotency index 충돌을 구분하지 않는다.

### 수정 대상

- `services/core/internal/storage/postgres.go`
- `services/core/internal/storage/postgres_test.go`
- 필요 시 `services/core/internal/storage/postgres_repo_test.go`

### 범위 결정 근거

- operation store duplicate conflict 처리만 수정한다.
- worker pick, operation event outbox, provider adapter, client, roadmap 문서 갱신은 제외한다.
- DB가 없는 local 환경에서도 핵심 분기 테스트가 가능하도록 constraint classification은 작은 helper로 분리하거나 동등한 방식으로 unit-testable하게 만든다.

### 빌드 등급

- Build: `local-G06`. Review: `cloud-G05`.
- Rationale: storage correctness follow-up이지만 이슈가 한 지점으로 명확하고 deterministic test로 검증 가능하다.

## 구현 체크리스트

- [ ] `CreateOperation`이 Postgres unique violation의 constraint를 구분해 `ux_operations_idempotency`일 때만 기존 idempotency row를 반환하고, `operations_pkey` 등 id collision은 idempotency key 유무와 무관하게 `ErrOperationAlreadyExists`로 반환하게 한다.
- [ ] id는 같고 idempotency key만 다른 duplicate create 테스트를 추가해 `ErrOperationAlreadyExists`를 검증한다.
- [ ] `go test -count=1 ./internal/storage`와 `go test -count=1 ./internal/core ./internal/storage`와 `go test -count=1 ./...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [REVIEW_API-1] Unique violation constraint routing

#### 문제

[services/core/internal/storage/postgres.go](/config/workspace/gito/services/core/internal/storage/postgres.go:527)의 unique violation 분기가 constraint 이름을 구분하지 않는다.

Before:

```go
if isUniqueViolation(err) {
    if op.IdempotencyKey != "" {
        existing, getErr := s.getByIdempotencyKey(ctx, op.IdempotencyKey)
        ...
        return OperationCreateResult{Operation: existing, Created: false}, nil
    }
    return OperationCreateResult{}, fmt.Errorf("%w: %s", ErrOperationAlreadyExists, op.ID)
}
```

`id=op-001`, `idempotency_key=key-a` row가 있는 상태에서 `id=op-001`, `idempotency_key=key-b`를 생성하면 실제 충돌은 `operations_pkey`다. 하지만 현재 코드는 `key-b`로 기존 row를 조회해 `ErrOperationNotFound` 계열 오류를 반환할 수 있다.

#### 해결 방법

- `pgconn.PgError.ConstraintName`을 확인하는 helper를 추가하거나 기존 `isUniqueViolation`을 확장한다.
- `ux_operations_idempotency` 충돌이고 요청 idempotency key가 비어 있지 않을 때만 `getByIdempotencyKey`를 호출한다.
- `operations_pkey` 또는 그 외 create duplicate로 해석해야 하는 unique violation은 `ErrOperationAlreadyExists`로 반환한다.
- helper를 만들 경우 DB 없이 `pgconn.PgError{Code: "23505", ConstraintName: ...}`로 분기 테스트를 작성한다.

After 예시:

```go
constraint, unique := uniqueViolationConstraint(err)
if unique {
    if constraint == "ux_operations_idempotency" && op.IdempotencyKey != "" {
        existing, getErr := s.getByIdempotencyKey(ctx, op.IdempotencyKey)
        ...
        return OperationCreateResult{Operation: existing, Created: false}, nil
    }
    return OperationCreateResult{}, fmt.Errorf("%w: %s", ErrOperationAlreadyExists, op.ID)
}
```

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

- [ ] `services/core/internal/storage/postgres.go`: constraint-aware unique violation 처리.
- [ ] `services/core/internal/storage/postgres_test.go`: 실제 Postgres integration duplicate id/different idempotency key 케이스 추가.
- [ ] `services/core/internal/storage/postgres_repo_test.go`: DB 없이 constraint routing helper 테스트 추가 또는 동등한 unit coverage 추가.

#### 테스트 작성

- Integration: 기존 `TestPostgresOperationStoreLifecycleAndIdempotency`에 `ID`는 기존 row와 같고 `IdempotencyKey`만 다른 operation create를 추가해 `errors.Is(err, storage.ErrOperationAlreadyExists)`를 검증한다.
- Unit: helper를 추가했다면 `operations_pkey`는 duplicate id, `ux_operations_idempotency`는 idempotency duplicate로 분기되는지 확인한다.

#### 중간 검증

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

Expected: storage package passes; `GITO_TEST_DATABASE_URL`이 없으면 Postgres integration은 기존 계약대로 skip될 수 있으나, DB 없는 helper/unit coverage는 실행되어야 한다.

## 수정 파일 요약

| 파일 | 항목 |
| --- | --- |
| `services/core/internal/storage/postgres.go` | REVIEW_API-1 |
| `services/core/internal/storage/postgres_test.go` | REVIEW_API-1 |
| `services/core/internal/storage/postgres_repo_test.go` | REVIEW_API-1 |

## 최종 검증

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

Expected: all packages pass; DB-specific tests may skip only if `GITO_TEST_DATABASE_URL` is unset, and DB 없이 실행되는 duplicate constraint 분기 테스트는 반드시 통과해야 한다.

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