<!-- task=m-webhook-revision-reconcile plan=1 tag=REVIEW_API -->

# Plan - REVIEW_API

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

`CODE_REVIEW-local-G05.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이 필수 마지막 단계다. 구현 후 active 파일은 그대로 두고 리뷰 준비 상태를 보고한다. 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 막으면 review stub의 `사용자 리뷰 요청`에 정확한 근거를 남기고 멈춘다. 직접 사용자에게 묻거나, 선택지를 채팅으로 제시하거나, `USER_REVIEW.md`, archive log, `complete.log`를 만들지 않는다.

## Archive Evidence Snapshot

- Archived plan: `agent-task/m-webhook-revision-reconcile/plan_local_G05_0.log`
- Archived review: `agent-task/m-webhook-revision-reconcile/code_review_local_G05_0.log`
- Verdict: FAIL
- Required issue: `ApplyReconcile`이 `reconcile:<idempotencyKey>` apply dedupe record를 side effect 성공 전에 선점한다. `HandleRevision`이 webhook/broadcast 실패로 error를 반환하면 자신의 delivery dedupe는 rollback하지만 선점 apply dedupe는 남아, 같은 key 재시도가 duplicate로 막힌다.
- Evidence: [runtime.go](/config/workspace/gito/services/core/internal/controlplane/runtime.go:1140), [runtime.go](/config/workspace/gito/services/core/internal/controlplane/runtime.go:1177), [runtime.go](/config/workspace/gito/services/core/internal/controlplane/runtime.go:1158)
- Verification state: 기존 focused/full Go test와 `git diff --check`는 통과했지만, 실패 후 같은 idempotency key 재시도 테스트가 없다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md`
- Task ids:
  - `expected-rev`: expected revision이 맞지 않으면 conflict로 멈춘다.
  - `dry-run`: reconcile dry-run 결과로 drift/conflict/proposal을 반환한다.
  - `apply`: idempotency key로 중복 적용 없이 change를 반영한다.
- Completion mode: check-on-pass

## Spec Targets

- SDD: `agent-roadmap/sdd/provider-change-request-integration/webhook-revision-reconcile/SDD.md`
- Acceptance scenarios:
  - `S08`: task=`expected-rev`; evidence=`expected revision mismatch test`
  - `S09`: task=`dry-run`; evidence=`reconcile dry-run test with drift/conflict/proposal output`
  - `S10`: task=`apply`; evidence=`idempotent apply test with repeated event/key and retry after failed publish`
- Completion mode: spec-check-on-pass

## 배경

기존 구현은 happy path duplicate를 막지만, 첫 apply가 `HandleRevision` 내부 publish 단계에서 실패하면 apply idempotency key가 먼저 소비된다. 이 후속 작업은 apply dedupe를 성공한 apply side effect와 일치시키고, 실패 후 재시도가 같은 key로 실제 apply를 완료할 수 있음을 증명한다.

## 구현 체크리스트

- [ ] `ApplyReconcile`에서 apply idempotency key가 side effect 성공 전에 영구 소비되지 않도록 수정한다.
- [ ] `HandleRevision(ctx, provider, "reconcile:"+IdempotencyKey, revision)`의 기존 durable dedupe를 재사용하거나, 별도 apply dedupe record를 유지한다면 모든 `HandleRevision` error 및 no-apply 경로에서 선점 record를 rollback한다.
- [ ] duplicate 결과는 성공한 기존 apply에 대해서만 반환하고, 실패한 첫 apply 뒤 같은 key 재시도는 duplicate가 아니라 적용 재시도로 진행되게 한다.
- [ ] `TestRuntimeApplyReconcileRetryAfterPublishFailure`를 추가해 첫 apply publish 실패 후 같은 idempotency key 재시도가 성공하는지 검증한다.
- [ ] 기존 `TestRuntimeApplyReconcileIsIdempotent`의 성공 후 duplicate 보장을 유지한다.
- [ ] 최종 검증 `cd services/core && go test ./internal/controlplane -run 'TestRuntimeReconcileExpectedRevisionConflict|TestRuntimeDryRunReconcileReportsDriftConflictProposal|TestRuntimeApplyReconcileIsIdempotent|TestRuntimeApplyReconcileRetryAfterPublishFailure' -count=1`, `cd services/core && go test ./...`, `git diff --check`를 실행한다.
- [ ] `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.

### [REVIEW_API-1] Apply idempotency retry contract 수정

#### 문제

[runtime.go](/config/workspace/gito/services/core/internal/controlplane/runtime.go:1140)의 선점 apply dedupe record가 [runtime.go](/config/workspace/gito/services/core/internal/controlplane/runtime.go:1177)의 실제 apply보다 먼저 저장된다. 실패 시 `HandleRevision`은 자기 delivery dedupe만 rollback하므로, 같은 key 재시도가 [runtime.go](/config/workspace/gito/services/core/internal/controlplane/runtime.go:1158)에서 duplicate로 멈출 수 있다.

#### 해결 방법

권장 경로는 `ApplyReconcile`에서 별도 선점 `RecordOnce`를 제거하고, `HandleRevision(ctx, provider, "reconcile:"+IdempotencyKey, revision)`이 이미 제공하는 `delivery:reconcile:<key>` durable dedupe 결과를 apply idempotency로 해석하는 것이다. `EventRecord.Duplicate`가 true이면 `ReconcileStatusDuplicate`를 반환하고, 첫 성공이면 `ReconcileStatusApplied`를 반환한다. 이렇게 하면 `HandleRevision`의 기존 rollback 정책이 실패 후 재시도를 허용한다.

별도 apply dedupe record를 유지하는 대안도 가능하지만, `HandleRevision` error, unmatched/no-apply, webhook failure, broadcast failure 경로에서 선점 record가 반드시 삭제되어야 한다. 이 경우 테스트는 같은 key 재시도가 성공 apply로 진행됨을 증명해야 한다.

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

- [ ] `services/core/internal/controlplane/runtime.go`: `ApplyReconcile` idempotency 처리 수정.
- [ ] `services/core/internal/controlplane/runtime.go`: duplicate 결과가 성공한 기존 apply에만 대응하도록 반환 조건 정리.
- [ ] `services/core/internal/controlplane/runtime_test.go`: 실패 후 재시도 회귀 테스트 추가.

#### 테스트 작성

- 작성: `services/core/internal/controlplane/runtime_test.go`.
- 테스트명: `TestRuntimeApplyReconcileRetryAfterPublishFailure`.
- assertion:
  - 같은 store/watch/request를 사용한다.
  - 첫 runtime은 `fakeBroadcaster{err: ...}` 또는 동등한 실패 publisher로 `ApplyReconcile`을 실행해 conflict/error result를 받는다.
  - 같은 idempotency key로 성공 broadcaster runtime에서 재시도하면 `Status=applied`, `Applied=true`, `Duplicate=false`가 된다.
  - 성공 재시도 후 event/envelope/cursor가 적용 결과를 반영한다.
  - 이후 같은 key를 한 번 더 호출하면 `Status=duplicate`이고 side effect count가 늘지 않는다.

#### 중간 검증

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

## 의존 관계 및 구현 순서

1. `ApplyReconcile` dedupe 흐름을 먼저 수정한다.
2. 실패 후 재시도 테스트를 추가한다.
3. 기존 S08/S09/S10 focused tests와 전체 core tests를 재실행한다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `services/core/internal/controlplane/runtime.go` | REVIEW_API-1 |
| `services/core/internal/controlplane/runtime_test.go` | REVIEW_API-1 |

## 최종 검증

```bash
cd services/core && go test ./internal/controlplane -run 'TestRuntimeReconcileExpectedRevisionConflict|TestRuntimeDryRunReconcileReportsDriftConflictProposal|TestRuntimeApplyReconcileIsIdempotent|TestRuntimeApplyReconcileRetryAfterPublishFailure' -count=1
cd services/core && go test ./...
git diff --check
```

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