<!-- task=m-webhook-revision-reconcile plan=0 tag=API -->

# Plan - API

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

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

## 배경

`poll-source`는 provider polling 입력 shape로 닫혔지만, reconcile 정책 Task 세 개가 아직 남아 있다. 현재 runtime은 `branch.updated` 이벤트와 revision identity dedupe는 갖고 있으나 expected revision conflict, dry-run result, apply idempotency를 하나의 실행 표면으로 제공하지 않는다. 이 계획은 기존 `ProviderDeliveries` dedupe와 `HandleRevision` side effect를 재사용해 작은 API 표면으로 S08-S10을 닫는다.

## 사용자 리뷰 요청 흐름

사용자 리뷰 요청은 선택된 SDD 결정 또는 선택된 Milestone lock 결정만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 중 직접 사용자에게 묻지 않으며, code-review가 요청의 정당성을 검증하고 실제 `USER_REVIEW.md` 작성을 소유한다.

## 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`
- Completion mode: spec-check-on-pass

## 분석 결과

### 읽은 파일

- `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-roadmap/current.md`
- `agent-roadmap/phase/provider-change-request-integration/PHASE.md`
- `agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md`
- `agent-roadmap/sdd/provider-change-request-integration/webhook-revision-reconcile/SDD.md`
- `services/core/internal/controlplane/runtime.go`
- `services/core/internal/controlplane/runtime_test.go`
- `services/core/internal/worker/runner.go`
- `services/core/internal/worker/runner_test.go`
- `services/core/internal/core/model.go`
- `.gitignore`

### 테스트 환경 규칙

- test_env: local.
- `agent-test/local/rules.md`는 존재하며 읽었다.
- 매칭 profile: `agent-test/local/core-smoke.md`.
- 적용 명령: core Go 변경은 `cd services/core && go test ./...`.
- focused 검증은 새 controlplane 테스트에 대해 `cd services/core && go test ./internal/controlplane -run 'TestRuntimeReconcileExpectedRevisionConflict|TestRuntimeDryRunReconcileReportsDriftConflictProposal|TestRuntimeApplyReconcileIsIdempotent' -count=1`.
- 구조상 빈 규칙이나 `<확인 필요>` 값은 없다.

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

- expected revision mismatch: 현재 coverage 없음. 새 `TestRuntimeReconcileExpectedRevisionConflict` 필요.
- dry-run drift/conflict/proposal: 현재 coverage 없음. 새 `TestRuntimeDryRunReconcileReportsDriftConflictProposal` 필요.
- idempotent apply: `ProviderDelivery` duplicate coverage는 있으나 reconcile idempotency key apply coverage는 없음. 새 `TestRuntimeApplyReconcileIsIdempotent` 필요.

### 심볼 참조

- 이름 변경/삭제 없음.

### 분할 판단

- split decision policy를 먼저 평가했다.
- 대상은 모두 `services/core/internal/controlplane/runtime.go`의 단일 runtime API와 `runtime_test.go` 테스트에 묶인다.
- `apply`는 `dry-run` 결과를 재사용하지만 별도 선행 `complete.log`가 필요한 ownership/API rollout 경계는 아니다.
- DB schema, provider adapter, worker rollout은 제외하므로 multi-plan split보다 단일 plan이 리뷰와 completion target 정합성에 더 안전하다.

### 범위 결정 근거

- `services/core/internal/worker/**`는 호출자 rollout을 하지 않는다. 기존 worker revision scan은 이미 source Task에 해당하며 reconcile action 후보와 분리한다.
- `services/core/internal/storage/**`와 migration은 수정하지 않는다. `ProviderDeliveries` store의 기존 idempotency를 재사용한다.
- 외부 REST/proto-socket endpoint는 추가하지 않는다. 이 Milestone Task는 action 후보와 runtime API 검증으로 닫는다.
- provider-specific API 호출은 추가하지 않는다.

### 빌드 등급

- local-G05: 단일 core runtime API와 targeted unit tests로 검증 가능하지만 idempotent side effect와 SDD completion anchor가 있어 중간 위험이다.

## 구현 체크리스트

- [ ] Reconcile request/result 모델과 expected revision conflict 경로를 구현하고 `TestRuntimeReconcileExpectedRevisionConflict`를 통과시킨다.
- [ ] Dry-run 결과가 drift/conflict/proposal을 반환하도록 구현하고 `TestRuntimeDryRunReconcileReportsDriftConflictProposal`를 통과시킨다.
- [ ] Apply 경로가 idempotency key로 side effect를 1회만 실행하도록 구현하고 `TestRuntimeApplyReconcileIsIdempotent`를 통과시킨다.
- [ ] 최종 검증 `cd services/core && go test ./...`와 `git diff --check`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [API-1] Reconcile 모델과 expected revision conflict

#### 문제

[runtime.go](/config/workspace/gito/services/core/internal/controlplane/runtime.go:55)는 event record와 poll shape만 제공하고 reconcile request/result가 없다. expected revision mismatch를 apply 없이 conflict로 멈추는 API와 테스트가 없다.

Before:

```go
55 type EventRecord struct {
56 	ID         string             `json:"id"`
57 	Type       string             `json:"type"`
58 	Provider   string             `json:"provider"`
59 	DeliveryID string             `json:"delivery_id,omitempty"`
60 	Duplicate  bool               `json:"duplicate,omitempty"`
61 	Revision   core.RevisionEvent `json:"revision"`
62 	CreatedAt  time.Time          `json:"created_at"`
63 }
65 type ProviderPollRevision struct {
```

#### 해결 방법

`ProviderPollRevision` 뒤에 `ReconcileStatus`, `ReconcileRequest`, `ReconcileProposal`, `ReconcileResult`를 추가한다. `DryRunReconcile(ctx, req)`에서 `repo_id`, `branch`, `expected_revision`, `current_revision`, `target_revision`을 trim/검증하고 `ExpectedRevision != CurrentRevision`이면 `Status=conflict`, `Conflict=true`, `Applied=false`로 반환한다.

After:

```go
type ReconcileStatus string

const (
	ReconcileStatusNoop     ReconcileStatus = "noop"
	ReconcileStatusConflict ReconcileStatus = "conflict"
	ReconcileStatusProposal ReconcileStatus = "proposal"
	ReconcileStatusApplied  ReconcileStatus = "applied"
	ReconcileStatusDuplicate ReconcileStatus = "duplicate"
)

type ReconcileRequest struct {
	Provider         string
	RepoID           string
	Branch           string
	ExpectedRevision string
	CurrentRevision  string
	TargetRevision   string
	ChangedFiles     []core.ChangedFile
	IdempotencyKey   string
	ObservedAt       time.Time
}
```

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

- [ ] `services/core/internal/controlplane/runtime.go`: reconcile status/request/result 타입 추가.
- [ ] `services/core/internal/controlplane/runtime.go`: validation helper 또는 `DryRunReconcile` 내부 검증 추가.
- [ ] `services/core/internal/controlplane/runtime_test.go`: `TestRuntimeReconcileExpectedRevisionConflict` 추가.

#### 테스트 작성

- 작성: `services/core/internal/controlplane/runtime_test.go`.
- 테스트명: `TestRuntimeReconcileExpectedRevisionConflict`.
- assertion: expected/current mismatch는 conflict result를 반환하고, `r.ListEvents()`와 fake broadcaster envelope count가 증가하지 않는다.

#### 중간 검증

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

### [API-2] Dry-run drift/conflict/proposal 결과

#### 문제

[runtime.go](/config/workspace/gito/services/core/internal/controlplane/runtime.go:607)의 poll handler는 revision event를 바로 처리한다. [runtime.go](/config/workspace/gito/services/core/internal/controlplane/runtime.go:816)의 scan path도 변경을 바로 event로 전환한다. apply 없이 drift/conflict/proposal을 관찰하는 dry-run API가 없다.

Before:

```go
607 func (r *Runtime) HandleProviderPollRevision(ctx context.Context, poll ProviderPollRevision) (EventRecord, bool, error) {
...
631 	return r.HandleRevision(ctx, provider, "", revision)
632 }
816 func (r *Runtime) ScanBranchRevision(ctx context.Context, opts ScanRevisionOptions) (EventRecord, bool, error) {
```

#### 해결 방법

`DryRunReconcile`이 conflict가 아니면 `TargetRevision == CurrentRevision`일 때 `noop`, 다르면 `Status=proposal`, `Drift=true`, `Proposal.Before=CurrentRevision`, `Proposal.After=TargetRevision`, `ChangedFiles`를 반환한다. 이벤트 append, delivery, broadcast를 하지 않는다.

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

- [ ] `services/core/internal/controlplane/runtime.go`: `DryRunReconcile` 구현.
- [ ] `services/core/internal/controlplane/runtime.go`: result에 `Drift`, `Conflict`, `Proposal`, `Reason` 필드 포함.
- [ ] `services/core/internal/controlplane/runtime_test.go`: `TestRuntimeDryRunReconcileReportsDriftConflictProposal` 추가.

#### 테스트 작성

- 작성: `services/core/internal/controlplane/runtime_test.go`.
- 테스트명: `TestRuntimeDryRunReconcileReportsDriftConflictProposal`.
- assertion: conflict case, noop case, proposal/drift case를 모두 검증하고 dry-run이 side effect를 만들지 않음을 확인한다.

#### 중간 검증

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

### [API-3] Apply idempotency key와 side effect 1회 보장

#### 문제

[runtime.go](/config/workspace/gito/services/core/internal/controlplane/runtime.go:634)의 durable path는 provider delivery id 또는 revision identity로 dedupe한다. reconcile apply가 idempotency key를 필수로 받아 같은 apply 재처리 시 side effect를 한 번만 실행하는 API는 없다.

Before:

```go
643 	var dedupeKey string
644 	if r.store.ProviderDeliveries() != nil {
645 		dedupeKey = dedupeKeyFor(deliveryID, revision)
646 		delivery := core.ProviderDelivery{
...
661 		if !result.First {
662 			// Return idempotent response using original delivery's event id and timestamp.
663 			record.ID = result.ExistingEventID
664 			record.CreatedAt = result.ExistingCreatedAt
665 			record.Duplicate = true
666 			return record, true, nil
667 		}
```

#### 해결 방법

`ApplyReconcile(ctx, req)`을 추가한다. 먼저 `DryRunReconcile`을 호출해 conflict면 바로 반환한다. apply는 `IdempotencyKey`와 durable `ProviderDeliveries` store를 요구한다. 적용 시 `HandleRevision(ctx, provider, "reconcile:"+IdempotencyKey, revision)`을 호출해 기존 dedupe path를 재사용한다. duplicate record는 `Status=duplicate`, `Duplicate=true`, `Applied=false`로 반환하고 webhook/broadcast side effect가 늘지 않아야 한다.

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

- [ ] `services/core/internal/controlplane/runtime.go`: `ApplyReconcile` 구현.
- [ ] `services/core/internal/controlplane/runtime.go`: missing idempotency key 또는 missing durable dedupe store error 처리.
- [ ] `services/core/internal/controlplane/runtime_test.go`: `TestRuntimeApplyReconcileIsIdempotent` 추가.

#### 테스트 작성

- 작성: `services/core/internal/controlplane/runtime_test.go`.
- 테스트명: `TestRuntimeApplyReconcileIsIdempotent`.
- assertion: 첫 apply는 event와 broadcaster envelope를 1개 만들고 cursor를 target revision으로 갱신한다. 같은 idempotency key 재호출은 duplicate result를 반환하고 event/envelope count를 늘리지 않는다. expected/current mismatch apply는 side effect 없이 conflict로 끝난다.

#### 중간 검증

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

## 의존 관계 및 구현 순서

1. `API-1` 타입과 conflict 결과를 먼저 구현한다.
2. `API-2` dry-run이 `API-1` validation/result를 재사용한다.
3. `API-3` apply가 `API-2` dry-run 결과와 기존 `HandleRevision` durable dedupe를 재사용한다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `services/core/internal/controlplane/runtime.go` | API-1, API-2, API-3 |
| `services/core/internal/controlplane/runtime_test.go` | API-1, API-2, API-3 |

## 최종 검증

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

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