<!-- task=m-webhook-revision-reconcile/02+01_retry_delivery_store plan=1 tag=REVIEW_API -->

# Plan - REVIEW_API

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

`CODE_REVIEW-local-G05.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이 필수다. 구현 후 검증을 실행하고 실제 stdout/stderr를 붙인 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 판정, 로그 rename, `complete.log`, archive 이동은 code-review-skill 전용이다. 선택된 SDD 결정 또는 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막으면 review stub의 `사용자 리뷰 요청` 섹션에 연결 대상, 근거, 실행 명령, 재개 조건을 기록하고 멈춘다. 구현 중 사용자에게 직접 묻거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 환경/secret/service blocker, 일반 범위 변경, 후속 에이전트가 닫을 수 있는 검증 공백은 사용자 리뷰 요청이 아니다.

## 배경

이전 구현은 outbound webhook delivery 상태 저장소와 retry/backoff 모델을 추가했지만, enqueue 직후 dispatch path가 전역 pending queue를 집어 현재 event/subscription으로 처리한다. 또한 outcome persistence 오류를 무시해 S04의 핵심 evidence인 attempt state가 실제로 저장되지 않아도 성공처럼 보일 수 있다.

## 사용자 리뷰 요청 흐름

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

## Archive Evidence Snapshot

- Current archived plan: `agent-task/m-webhook-revision-reconcile/02+01_retry_delivery_store/plan_cloud_G06_0.log`
- Current archived review: `agent-task/m-webhook-revision-reconcile/02+01_retry_delivery_store/code_review_cloud_G06_0.log`
- Verdict: FAIL
- Required summary:
  - `services/core/internal/controlplane/runtime.go:200` calls global `PickPendingDeliveries(ctx, 1, now)` immediately after enqueue and `runtime.go:206` dispatches the picked delivery with the current loop's `sub` and `record`, so an older unrelated pending/retryable delivery can receive the wrong outcome.
  - `services/core/internal/controlplane/runtime.go:279` ignores `RecordOutcome` errors, so delivery attempt evidence can be lost while callers still report success.
- Suggested/Nit summary: 없음
- Affected files: `services/core/internal/controlplane/runtime.go`, `services/core/internal/controlplane/runtime_test.go`, `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`.
- Verification evidence: `cd services/core && go test -run 'TestWebhookDeliveryRetry|TestPostgresWebhookDelivery' ./internal/... -v -count=1` passed with PostgreSQL integration skipped because `GITO_TEST_DATABASE_URL` was unset; `cd services/core && go test ./...` passed.
- Roadmap/spec carryover: Roadmap task `retry`, SDD scenario `S04`.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md`
- Task ids:
  - `retry`: delivery idempotency, retry/backoff, failed delivery 상태를 PostgreSQL 기준으로 기록한다.
- Completion mode: check-on-pass

## Spec Targets

- SDD: `agent-roadmap/sdd/provider-change-request-integration/webhook-revision-reconcile/SDD.md`
- Acceptance scenarios:
  - `S04`: task=`retry`; evidence=`delivery retry/backoff unit or integration test with PostgreSQL store`
- Completion mode: spec-check-on-pass

## 분석 결과

### 읽은 파일

- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/private/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-roadmap/current.md`
- `agent-roadmap/sdd/provider-change-request-integration/webhook-revision-reconcile/SDD.md`
- `agent-contract/index.md`
- `agent-contract/provided/gito-forgejo-branch-events-v1.md`
- `agent-test/local/rules.md`
- `agent-test/local/core-smoke.md`
- `agent-ops/rules/project/domain/core/rules.md`
- `services/core/internal/controlplane/runtime.go`
- `services/core/internal/controlplane/runtime_test.go`
- `services/core/internal/controlplane/router.go`
- `services/core/cmd/server/main.go`
- `services/core/cmd/worker/main.go`
- `services/core/internal/core/model.go`
- `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`
- `services/core/migrations/00001_initial.sql`
- `.gitignore`
- `agent-task/archive/2026/06/m-webhook-revision-reconcile/01_http_delivery/complete.log`
- `agent-task/m-webhook-revision-reconcile/02+01_retry_delivery_store/plan_cloud_G06_0.log`
- `agent-task/m-webhook-revision-reconcile/02+01_retry_delivery_store/code_review_cloud_G06_0.log`

### 테스트 환경 규칙

- test_env: `local`.
- 적용 규칙: `agent-test/local/rules.md`, `agent-test/local/core-smoke.md`.
- core behavior change 필수 검증: `cd services/core && go test ./...`.
- Targeted retry 검증은 stale cache를 피하기 위해 `-count=1`을 사용한다.
- PostgreSQL-dependent test는 `GITO_TEST_DATABASE_URL`이 없으면 skip 가능하며, skip 출력과 residual risk를 review stub에 기록한다.

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

- 즉시 dispatch path가 전역 pending queue에서 unrelated delivery를 pick하지 않는지 검증하는 테스트가 없다.
- `RecordOutcome` 실패 시 `HandleRevision` 또는 `ProcessPendingWebhooks`가 오류를 반환하는지 검증하는 테스트가 없다.

### 심볼 참조

- renamed/removed symbol: none.
- 관련 심볼: `deliverWebhooks`, `dispatchAndRecordOutcome`, `ProcessPendingWebhooks`, `WebhookDeliveryStore.PickPendingDeliveries`, `WebhookDeliveryStore.RecordOutcome`.

### 분할 판단

- 기존 split subtask `02+01_retry_delivery_store` 안의 follow-up이다.
- predecessor `01`은 `agent-task/archive/2026/06/m-webhook-revision-reconcile/01_http_delivery/complete.log`로 충족되었다.
- 변경은 runtime delivery path와 해당 tests에 집중되며 두 Required가 같은 call flow에 있어 추가 split은 만들지 않는다.

### 범위 결정 근거

- Durable subscription/event storage, daemon scheduling loop, worker process wiring은 이번 follow-up 범위가 아니다.
- PostgreSQL schema는 변경하지 않는다. Storage interface와 Postgres query는 id-scoped `StartDelivery` method 추가에만 제한한다.
- Provider callback dedupe, payload/header construction, NomadCode-specific behavior는 변경하지 않는다.

### 빌드 등급

- build/review: `local-G05`. bounded Go runtime bugfix이며 deterministic unit tests와 existing core smoke로 검증 가능하다.

## 의존 관계 및 구현 순서

1. 즉시 dispatch가 current enqueue delivery id만 처리하도록 수정한다.
2. outcome 기록 실패를 반환/전파하도록 수정한다.
3. 두 회귀 테스트를 추가하고 targeted/full 검증을 실행한다.

## 구현 체크리스트

- [ ] 즉시 dispatch path가 id-scoped `StartDelivery`로 방금 enqueue한 delivery만 `sending` 전이 후 dispatch/record하도록 수정한다.
- [ ] `RecordOutcome` 실패가 무시되지 않도록 `dispatchAndRecordOutcome`과 caller들이 오류를 반환/전파하게 수정한다.
- [ ] unrelated pending delivery와 outcome 기록 실패를 각각 재현하는 regression test를 작성한다.
- [ ] `cd services/core && go test -run 'TestWebhookDeliveryRetryBackoffRecordsState|TestWebhookDeliveryDoesNotDispatchUnrelatedPendingDelivery|TestWebhookDeliveryRecordOutcomeError' ./internal/controlplane -v -count=1`를 실행한다.
- [ ] `cd services/core && go test -run 'TestWebhookDeliveryRetry|TestPostgresWebhookDelivery' ./internal/... -v -count=1`를 실행한다.
- [ ] `cd services/core && go test ./...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## [REVIEW_API-1] Dispatch Only The Enqueued Delivery

### 문제

`services/core/internal/controlplane/runtime.go:200`에서 새 delivery enqueue 직후 `PickPendingDeliveries(ctx, 1, now)`를 호출한다. 이 method는 전역 pending/retryable queue에서 가장 이른 항목을 반환하므로, 이미 due 상태인 unrelated delivery가 있으면 `runtime.go:206`에서 현재 loop의 `sub`/`record`로 잘못 dispatch하고 unrelated delivery id에 outcome을 기록한다.

### 해결 방법

즉시 dispatch path는 방금 enqueue한 delivery만 처리해야 한다. `PickPendingDeliveries`를 즉시 path에 쓰지 말고 `WebhookDeliveryStore`에 `StartDelivery(ctx, id, now)`를 추가한다. Postgres 구현은 `WHERE id=$1 AND status IN ('pending', 'retryable') AND next_attempt_at <= $2` guard로 한 row만 `sending` 전이시키고, 이미 due가 아니거나 terminal 상태이면 `(zero, false, nil)`을 반환한다.

Before:

```go
// services/core/internal/controlplane/runtime.go:200
if res.Created {
	picked, err := r.store.WebhookDeliveries().PickPendingDeliveries(ctx, 1, time.Now().UTC())
	if err != nil {
		return fmt.Errorf("pick pending delivery: %w", err)
	}
	for _, d := range picked {
		r.dispatchAndRecordOutcome(ctx, d, sub, record)
	}
}
```

After shape:

```go
// services/core/internal/controlplane/runtime.go
if res.Created {
	started, ok, err := r.store.WebhookDeliveries().StartDelivery(ctx, res.Delivery.ID, time.Now().UTC())
	if err != nil {
		return fmt.Errorf("start delivery: %w", err)
	}
	if ok {
		if err := r.dispatchAndRecordOutcome(ctx, started, sub, record); err != nil {
			return err
		}
	}
}
```

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

- [ ] `services/core/internal/controlplane/runtime.go`: immediate dispatch에서 전역 pick을 제거하고 current delivery id만 claim/dispatch한다.
- [ ] `services/core/internal/storage/storage.go`: `WebhookDeliveryStore.StartDelivery(ctx, id, now)`를 interface에 추가한다.
- [ ] `services/core/internal/storage/postgres.go`: `StartDelivery` guarded update query를 추가한다.
- [ ] `services/core/internal/controlplane/runtime_test.go`: unrelated pending delivery regression test를 추가한다.
- [ ] `services/core/internal/storage/storage_test.go`: storage fake compile checks를 갱신한다.
- [ ] `services/core/internal/storage/postgres_test.go`: 기존 Postgres retry test에 `StartDelivery` id-scoped transition assertion을 추가한다.

### 테스트 작성

`TestWebhookDeliveryDoesNotDispatchUnrelatedPendingDelivery`를 `runtime_test.go`에 추가한다. Fake store에 더 이른 `pending`/`retryable` delivery를 미리 넣고 새 event/subscription을 처리한다. Assertion: 새 event delivery만 outcome이 기록되고, 미리 있던 unrelated delivery는 current event payload/subscription outcome으로 변경되지 않는다.

### 중간 검증

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

Expected: 새 regression test가 통과한다.

## [REVIEW_API-2] Propagate Outcome Persistence Errors

### 문제

`services/core/internal/controlplane/runtime.go:279`에서 `RecordOutcome` 결과와 error를 모두 버린다. 이러면 HTTP response classification은 되었지만 PostgreSQL attempt state가 저장되지 않은 경우에도 callback/worker flow가 성공처럼 끝나며, delivery가 `sending`에 갇히거나 retry/backoff evidence가 사라질 수 있다.

### 해결 방법

`dispatchAndRecordOutcome`이 `error`를 반환하게 만들고, `deliverWebhooks`와 `ProcessPendingWebhooks`가 이를 전파한다. Missing subscription/event를 failed로 기록하는 branch도 `RecordOutcome` 실패를 무시하지 말고 반환한다.

Before:

```go
// services/core/internal/controlplane/runtime.go:279
_, _ = r.store.WebhookDeliveries().RecordOutcome(
	ctx,
	d.ID,
	outcome.status,
	outcome.statusCode,
	outcome.lastErr,
	outcome.nextAttemptAt,
	time.Now().UTC(),
)
```

After shape:

```go
updated, err := r.store.WebhookDeliveries().RecordOutcome(ctx, d.ID, outcome.status, outcome.statusCode, outcome.lastErr, outcome.nextAttemptAt, time.Now().UTC())
if err != nil {
	return fmt.Errorf("record webhook delivery outcome %s: %w", d.ID, err)
}
_ = updated
return nil
```

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

- [ ] `services/core/internal/controlplane/runtime.go`: `dispatchAndRecordOutcome` signature를 `error` 반환으로 바꾼다.
- [ ] `services/core/internal/controlplane/runtime.go`: immediate dispatch, `ProcessPendingWebhooks`, missing subscription/event outcome 기록 branch에서 error를 전파한다.
- [ ] `services/core/internal/controlplane/runtime_test.go`: outcome persistence failure regression test를 추가한다.

### 테스트 작성

`TestWebhookDeliveryRecordOutcomeError`를 `runtime_test.go`에 추가한다. Fake webhook store가 `RecordOutcome`에서 sentinel error를 반환하도록 하고, HTTP server는 200을 반환한다. Assertion: `HandleRevision` 또는 `ProcessPendingWebhooks`가 error를 반환하고 error message가 outcome record 실패를 포함한다.

### 중간 검증

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

Expected: 새 regression test가 통과한다.

## 수정 파일 요약

| 파일 | 항목 |
| --- | --- |
| `services/core/internal/controlplane/runtime.go` | REVIEW_API-1, REVIEW_API-2 |
| `services/core/internal/controlplane/runtime_test.go` | REVIEW_API-1, REVIEW_API-2 |
| `services/core/internal/storage/storage.go` | REVIEW_API-1 |
| `services/core/internal/storage/postgres.go` | REVIEW_API-1 |
| `services/core/internal/storage/storage_test.go` | REVIEW_API-1 |
| `services/core/internal/storage/postgres_test.go` | REVIEW_API-1 |

## 최종 검증

```bash
cd services/core && go test -run 'TestWebhookDeliveryRetryBackoffRecordsState|TestWebhookDeliveryDoesNotDispatchUnrelatedPendingDelivery|TestWebhookDeliveryRecordOutcomeError' ./internal/controlplane -v -count=1
cd services/core && go test -run 'TestWebhookDeliveryRetry|TestPostgresWebhookDelivery' ./internal/... -v -count=1
cd services/core && go test ./...
```

Expected: all commands exit 0. PostgreSQL-specific test may skip only when `GITO_TEST_DATABASE_URL` is unset; record the skip output and residual risk.

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