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

# Plan - API

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

`CODE_REVIEW-cloud-G06.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이 필수다. 이 task directory 이름의 `+01`은 runtime dependency다. `agent-task/m-webhook-revision-reconcile/01_http_delivery/complete.log` 또는 matching archive `complete.log`가 없으면 구현을 시작하지 말고 review stub에 dependency blocker를 기록한다. 구현 후 검증을 실행하고 실제 stdout/stderr를 붙인 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 판정, 로그 rename, `complete.log`, archive 이동은 code-review-skill 전용이다. 선택된 SDD 결정 또는 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막으면 review stub의 `사용자 리뷰 요청` 섹션에 연결 대상, 근거, 실행 명령, 재개 조건을 기록하고 멈춘다. 구현 중 사용자에게 직접 묻거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다.

## 배경

`retry`는 단순 HTTP dispatch가 아니라 delivery idempotency, retry/backoff, failed delivery 상태를 PostgreSQL 기준으로 남겨야 하는 storage-backed task다. 기존 `provider_deliveries`는 Forgejo/provider callback dedupe용이며 outbound consumer webhook attempt 상태를 표현하지 않는다. 이 plan은 `01_http_delivery`가 HTTP delivery path를 만든 뒤 그 경로를 durable queue/attempt state로 확장한다.

## 사용자 리뷰 요청 흐름

구현 중 직접 사용자에게 묻지 않는다. 선택된 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:
  - `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/plan/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.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`
- `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-test/local/contracts-smoke.md`
- `agent-test/local/workspace-ops-smoke.md`
- `agent-ops/rules/project/domain/core/rules.md`
- `agent-ops/rules/project/domain/contracts/rules.md`
- `services/core/go.mod`
- `services/core/cmd/server/main.go`
- `services/core/cmd/worker/main.go`
- `services/core/internal/core/model.go`
- `services/core/internal/controlplane/router.go`
- `services/core/internal/controlplane/router_test.go`
- `services/core/internal/controlplane/runtime.go`
- `services/core/internal/controlplane/runtime_test.go`
- `services/core/internal/events/events.go`
- `services/core/internal/storage/storage.go`
- `services/core/internal/storage/storage_test.go`
- `services/core/internal/storage/postgres.go`
- `services/core/internal/storage/postgres_test.go`
- `services/core/internal/worker/runner.go`
- `services/core/migrations/00001_initial.sql`
- `.gitignore`

### 테스트 환경 규칙

- test_env: `local`.
- 적용 규칙: `agent-test/local/rules.md`와 `agent-test/local/core-smoke.md`를 읽었다.
- 적용 명령: core behavior change는 `cd services/core && go test ./...`가 필수다.
- PostgreSQL-dependent tests may skip when `GITO_TEST_DATABASE_URL` is unset; skipped integration prerequisite must be reported with residual risk.
- 이 plan은 migration/storage 변경이 있으므로 targeted store tests와 full core smoke를 모두 둔다.

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

- `ProviderDeliveryStore.RecordOnce`는 provider callback dedupe만 커버한다.
- Outbound webhook delivery attempt status, retryable/non-retryable classification, backoff time, retry budget, failed terminal state는 아직 모델/스토어/테스트가 없다.
- `operation_events` outbox pending/published는 generic event publish 용도이며 per-subscription delivery attempt 상태를 대체하지 않는다.

### 심볼 참조

- renamed/removed symbol: none. 추가 interface가 생기면 `storage.Store`, `storage.Boundary`, `storage.PgStore`, test fakes 전체를 갱신해야 한다.

### 분할 판단

- split policy 평가 결과, 이 작업은 storage/migration/attempt state를 다루므로 `01_http_delivery`와 분리했다.
- task group: `agent-task/m-webhook-revision-reconcile`.
- current subtask: `02+01_retry_delivery_store`.
- predecessor `01`: missing in active path now. Required completion file before implementation: `agent-task/m-webhook-revision-reconcile/01_http_delivery/complete.log`, or matching archived path `agent-task/archive/*/*/m-webhook-revision-reconcile/01_http_delivery/complete.log`.

### 범위 결정 근거

- HTTP payload/header construction itself는 `01_http_delivery` 책임이다. 이 task는 successful/failed HTTP 결과를 durable delivery state로 남기고 retry scheduling이 가능한 store boundary를 만든다.
- Real background worker loop wiring은 최소 범위로만 포함한다. 별도 daemon scheduling이 필요해지면 follow-up으로 남기되, S04 증거는 store-level retry/backoff test로 닫는다.
- Provider polling/reconcile dry-run/apply는 delivery epic 밖이므로 제외한다.

### 빌드 등급

- build/review: `cloud-G06`. PostgreSQL schema, storage interface, state machine, retry semantics가 얽혀 있고 review가 migration/test evidence를 함께 판단해야 한다.

## 의존 관계 및 구현 순서

1. `01_http_delivery` PASS 후 `complete.log`가 존재하는지 확인한다.
2. Outbound delivery 상태 모델과 storage interface를 추가한다.
3. Migration/Postgres store를 구현한다.
4. Runtime delivery path를 store-backed enqueue/attempt recording으로 연결한다.
5. retry/backoff tests와 full smoke를 실행한다.

## 구현 체크리스트

- [ ] 선행 `01_http_delivery`의 `complete.log` 존재를 확인하고 경로를 `검증 결과`에 기록한다.
- [ ] outbound webhook delivery 상태 모델과 store interface를 추가한다.
- [ ] PostgreSQL migration과 `PgStore` 구현으로 pending/sending/succeeded/retryable/failed 상태, attempt count, next_attempt_at, last_error/status_code를 기록한다.
- [ ] delivery response classifier를 추가해 2xx는 succeeded, 429/5xx/transport는 retryable with backoff, 400/401/403/404/410은 failed로 기록한다.
- [ ] 같은 event/subscription 조합이 중복 enqueue되어도 delivery idempotency가 유지되는 테스트를 작성한다.
- [ ] `cd services/core && go test -run 'TestWebhookDeliveryRetry|TestPostgresWebhookDelivery' ./internal/... -v -count=1`를 실행한다.
- [ ] `cd services/core && go test ./...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## [API-1] Durable Webhook Delivery Store

### 문제

`storage.go:13`의 `Store`에는 outbound webhook delivery store가 없다. `postgres.go:851`의 `ProviderDeliveryStore`는 provider callback dedupe만 기록하며 retry status, attempt count, next attempt time, response status를 저장하지 않는다.

Before:

```go
// services/core/internal/storage/storage.go:13
type Store interface {
	Ping(ctx context.Context) error
	Repos() RepoStore
	WorkspaceLeases() WorkspaceLeaseStore
	Operations() OperationStore
	OperationEvents() OperationEventStore
	BranchWatches() BranchWatchStore
	RevisionCursors() RevisionCursorStore
	ProviderDeliveries() ProviderDeliveryStore
	AgentRunInputs() AgentRunInputStore
}
```

```go
// services/core/internal/storage/postgres.go:851
func (s *pgProviderDeliveryStore) RecordOnce(ctx context.Context, delivery core.ProviderDelivery) (DeliveryResult, error) {
	const insert = `
		INSERT INTO provider_deliveries
			(id, provider, delivery_id, dedupe_key, event_id, repo_id, branch, revision, created_at)
```

### 해결 방법

새 outbound 모델을 `core`에 추가하고 provider callback dedupe와 분리한다.

After shape:

```go
// services/core/internal/core/model.go
type WebhookDeliveryStatus string

const (
	WebhookDeliveryPending   WebhookDeliveryStatus = "pending"
	WebhookDeliverySending   WebhookDeliveryStatus = "sending"
	WebhookDeliverySucceeded WebhookDeliveryStatus = "succeeded"
	WebhookDeliveryRetryable WebhookDeliveryStatus = "retryable"
	WebhookDeliveryFailed    WebhookDeliveryStatus = "failed"
)

type WebhookDelivery struct {
	ID             string
	EventID        string
	SubscriptionID string
	Status         WebhookDeliveryStatus
	AttemptCount   int
	NextAttemptAt  time.Time
	LastStatusCode int
	LastError       string
	CreatedAt      time.Time
	UpdatedAt      time.Time
}
```

Add `WebhookDeliveryStore` to `storage.Store` with enqueue/pick/record methods. Update `Boundary`, `PgStore`, `storage_test.go` fakes, and `runtime_test.go` fake store.

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

- [ ] `services/core/internal/core/model.go`: add webhook delivery status/model and status validation helper.
- [ ] `services/core/internal/storage/storage.go`: add `WebhookDeliveryStore` interface and `WebhookDeliveries()` method.
- [ ] `services/core/internal/storage/postgres.go`: add `pgWebhookDeliveryStore`.
- [ ] `services/core/migrations/00001_initial.sql`: add `webhook_deliveries` table and indexes.
- [ ] `services/core/internal/storage/storage_test.go`: update compile fakes and migration snippets.
- [ ] `services/core/internal/storage/postgres_test.go`: add Postgres integration test; skip cleanly without `GITO_TEST_DATABASE_URL`.
- [ ] `services/core/internal/controlplane/runtime_test.go`: update `fakeStore`.

### 테스트 작성

Write `TestPostgresWebhookDeliveryStoreRecordsRetryState` in `postgres_test.go`. Assertions: first enqueue creates pending, duplicate enqueue returns existing/no duplicate, pick moves to sending, retryable record increments attempt count and sets `next_attempt_at`, terminal failed and succeeded states are persisted.

### 중간 검증

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

Expected: exits 0 when `GITO_TEST_DATABASE_URL` is set; otherwise skips with the existing PostgreSQL prerequisite message.

## [API-2] Retry Classification And Backoff

### 문제

After `01_http_delivery`, HTTP delivery can fail, but S04 requires retry/backoff and failed state to be recorded. There is no classifier for 429/5xx vs non-retryable 4xx, and no retry budget.

Before:

```go
// services/core/internal/controlplane/runtime.go:310
if r.broadcaster != nil {
	if err := r.broadcaster.BroadcastEnvelope(ctx, branchUpdatedEnvelope(record)); err != nil {
		// Rollback delivery record on broadcast failure
		if r.store.ProviderDeliveries() != nil && dedupeKey != "" {
```

### 해결 방법

Keep provider event broadcast separate from outbound webhook delivery. Wrap the HTTP delivery result from `01_http_delivery` in a classifier:

```go
func classifyWebhookDelivery(resp *http.Response, err error, attempt int, now time.Time) webhookDeliveryOutcome {
	switch {
	case err != nil:
		return retryableOutcome(now, attempt)
	case resp.StatusCode >= 200 && resp.StatusCode < 300:
		return succeededOutcome()
	case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500:
		return retryableOutcome(now, attempt)
	default:
		return failedOutcome(resp.StatusCode)
	}
}
```

Use bounded exponential backoff with deterministic unit tests. Do not log raw target URL, raw secret, or private response bodies.

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

- [ ] `services/core/internal/controlplane/runtime.go`: record delivery outcome through `WebhookDeliveryStore`.
- [ ] `services/core/internal/controlplane/runtime_test.go`: add fake store tests for retryable, failed, succeeded outcomes.
- [ ] `services/core/internal/storage/storage.go`: expose status recording primitives needed by runtime.

### 테스트 작성

Write `TestWebhookDeliveryRetryBackoffRecordsState` in `runtime_test.go`. Use `httptest.Server` returning 500 then 200 or just a deterministic fake transport. Assert attempt count, retryable state, `next_attempt_at` after now, and no duplicate delivery row for repeated event/subscription enqueue.

### 중간 검증

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

Expected: exits 0 and records retryable state plus next attempt time.

## 수정 파일 요약

| 파일 | 항목 |
| --- | --- |
| `services/core/internal/core/model.go` | API-1 |
| `services/core/internal/storage/storage.go` | API-1, API-2 |
| `services/core/internal/storage/postgres.go` | API-1 |
| `services/core/migrations/00001_initial.sql` | API-1 |
| `services/core/internal/storage/storage_test.go` | API-1 |
| `services/core/internal/storage/postgres_test.go` | API-1 |
| `services/core/internal/controlplane/runtime.go` | API-2 |
| `services/core/internal/controlplane/runtime_test.go` | API-2 |

## 최종 검증

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

Expected: both commands exit 0. PostgreSQL-specific test may skip only when `GITO_TEST_DATABASE_URL` is unset; record the skip output and residual risk. Cached output is acceptable for the full package smoke, but targeted retry tests use `-count=1`.

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