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

# Plan - REVIEW_API

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

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

## 배경

1차 구현은 webhook delivery happy path와 fake consumer test를 추가했지만, outbound signature와 delivery identity가 SDD/계약과 맞지 않고, store-backed failure retry가 막힐 수 있다. 이 follow-up은 기존 API slice를 유지하면서 계약 위반과 재시도 회귀만 수정한다. PostgreSQL delivery outbox/retry persistence 자체는 여전히 `02+01_retry_delivery_store` 범위다.

## 사용자 리뷰 요청 흐름

구현 중 직접 사용자에게 묻지 않는다. 선택된 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:
  - `deliver`: `branch.updated` 같은 normalized event를 signed HTTP POST로 전달한다.
  - `nomadcode-wakeup`: NomadCode dev consumer가 generic `branch.updated` HTTP webhook delivery를 wakeup으로 매핑할 수 있는 interop path를 검증한다.
- Completion mode: check-on-pass

## Spec Targets

- SDD: `agent-roadmap/sdd/provider-change-request-integration/webhook-revision-reconcile/SDD.md`
- Acceptance scenarios:
  - `S02`: task=`deliver`; evidence=`cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1`
  - `S03`: task=`nomadcode-wakeup`; evidence=`fake NomadCode consumer test receives generic branch.updated without NomadCode-only required fields`
- Completion mode: spec-check-on-pass

## Archive Evidence Snapshot

- Current archived plan: `agent-task/m-webhook-revision-reconcile/01_http_delivery/plan_local_G05_0.log`
- Current archived review: `agent-task/m-webhook-revision-reconcile/01_http_delivery/code_review_local_G05_0.log`
- Verdict: FAIL
- Required summary:
  - `runtime.go:173`: outbound `X-Gito-Signature` is `SHA256(secret + body)`, not HMAC-SHA256.
  - `runtime.go:217`: `secret_ref` can send unsigned delivery when resolver is absent/unresolved.
  - `runtime.go:214`: `X-Gito-Delivery` can be empty when provider delivery id is absent.
  - `runtime.go:456`/`runtime.go:474`: webhook failure leaves provider delivery dedupe record, blocking same-delivery retry.
- Suggested summary: 없음
- Nit summary: 없음
- Affected files:
  - `services/core/internal/controlplane/runtime.go`
  - `services/core/internal/controlplane/runtime_test.go`
- Verification evidence:
  - `cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1` - PASS, but current test does not validate the HMAC contract or empty delivery-id edge.
  - `cd services/core && go test ./...` - PASS
  - `git diff --check` - PASS
- Roadmap carryover: `deliver`, `nomadcode-wakeup`
- Spec carryover: `S02`, `S03`
- Narrow archive reread allowed: the two archived log paths above only if details are needed.

## 분석 결과

### 읽은 파일

- `agent-task/m-webhook-revision-reconcile/01_http_delivery/plan_local_G05_0.log`
- `agent-task/m-webhook-revision-reconcile/01_http_delivery/code_review_local_G05_0.log`
- `agent-contract/provided/gito-forgejo-branch-events-v1.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/controlplane/router.go`
- `services/core/internal/provider/forgejo/push.go`
- `services/core/internal/storage/storage.go`
- `services/core/internal/storage/postgres.go`

### 범위 결정 근거

- 이 follow-up은 outbound HTTP delivery 계약 준수와 retry-blocking 회귀만 수정한다.
- `/api/webhook-subscriptions` route shape, durable delivery outbox table, async retry/backoff worker는 새로 만들지 않는다.
- `secret_ref`를 실제 raw secret 저장으로 바꾸지 않는다. resolver가 없거나 unresolved이면 unsigned delivery를 보내지 않는 오류 경로로 처리한다.
- Roadmap/SDD target은 그대로 유지한다. Required 수정이 완료되면 `deliver`와 `nomadcode-wakeup` PASS 후보가 된다.

### 라우팅

- build/review: `cloud-G06`
- 근거: 수정 파일 밖에서 약속된 동작까지 맞아야 하고, 잘못된 테스트도 통과할 수 있어 보수적으로 cloud로 라우팅한다.

## 구현 체크리스트

- [ ] `signPayload`를 `crypto/hmac` 기반 HMAC-SHA256으로 고치고, `secret_ref` 설정 시 signature 계약을 검증하는 테스트를 실제 HMAC 기대값으로 바꾼다.
- [ ] `secret_ref`가 있는데 resolver가 없거나 unresolved인 경우 unsigned delivery를 보내지 않고 오류 처리하며, 기본 runtime/router 경로의 실패 테스트를 추가한다.
- [ ] provider delivery id가 비어도 `X-Gito-Delivery`가 stable non-empty 값이 되도록 fallback delivery id를 만들고 payload/header 테스트를 추가한다.
- [ ] store-backed webhook delivery 실패 시 provider delivery dedupe record가 남아 retry를 막지 않도록 rollback/ordering을 고치고 실패 후 재시도 테스트를 추가한다.
- [ ] `cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1`를 실행한다.
- [ ] `cd services/core && go test -run 'TestWebhookDeliveryBranchUpdated|TestRuntime_WebhookDeliveryWithSecret|TestRuntime_WebhookDeliveryUnsignedSecretRefFails|TestRuntime_WebhookDeliveryFallbackDeliveryID|TestRuntime_WebhookDeliveryFailureAllowsRetry' ./internal/controlplane/ -v -count=1`를 실행한다.
- [ ] `cd services/core && go test ./...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## [REVIEW_API-1] HMAC-SHA256 Signature Contract

### 문제

`services/core/internal/controlplane/runtime.go:173`의 `signPayload`가 `sha256(secret + body)`를 계산한다. 계약은 `X-Gito-Signature`가 request body에 대한 HMAC-SHA256 digest여야 한다고 정의한다.

### 해결 방법

`crypto/hmac`을 사용해 `hmac.New(sha256.New, []byte(secret))`로 MAC을 만들고, body bytes만 `Write`한다. 가능하면 `signPayload(secret string, body []byte)`처럼 문자열 재변환을 줄여 테스트와 production path가 같은 bytes를 쓰게 한다.

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

- [ ] `services/core/internal/controlplane/runtime.go`: `signPayload`를 HMAC-SHA256 구현으로 변경한다.
- [ ] `services/core/internal/controlplane/runtime_test.go`: `TestRuntime_WebhookDeliveryWithSecret` 기대값을 `crypto/hmac` 기반으로 변경한다.

### 테스트 작성

`TestRuntime_WebhookDeliveryWithSecret`가 실제 HMAC 값을 검증해야 한다. 기존 `sha256(secret + body)` 기대값을 제거한다.

### 중간 검증

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

## [REVIEW_API-2] Secret Ref Must Not Send Unsigned Delivery

### 문제

`services/core/internal/controlplane/runtime.go:217`는 `sub.SecretRef != ""`이어도 `r.secretResolver == nil`이면 서명 없이 POST를 보낸다. resolver가 있더라도 unresolved이면 동일하게 unsigned delivery가 나간다. 계약은 `secret_ref` 설정 시 `X-Gito-Signature`를 필수로 둔다.

### 해결 방법

`sub.SecretRef != ""`이면 resolver 부재, resolver error, unresolved 모두 delivery error로 처리한다. 지금 slice에서 production secret backend를 새로 만들지 않아도 되지만, secret_ref가 있는 subscription을 unsigned로 보내면 안 된다.

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

- [ ] `services/core/internal/controlplane/runtime.go`: `secret_ref`가 있는 경우 resolver 부재/미해결을 error로 반환한다.
- [ ] `services/core/internal/controlplane/runtime_test.go`: `TestRuntime_WebhookDeliveryUnsignedSecretRefFails`를 추가해 unsigned POST가 발생하지 않음을 검증한다.

### 테스트 작성

테스트는 secret_ref가 있는 subscription과 resolver nil 또는 unresolved resolver를 구성하고, `HandleRevision`이 error를 반환하며 fake consumer가 request를 받지 않았음을 확인한다.

### 중간 검증

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

## [REVIEW_API-3] Stable Non-Empty Consumer Delivery ID

### 문제

`services/core/internal/controlplane/runtime.go:214`는 `X-Gito-Delivery`를 provider delivery id에만 의존한다. Forgejo callback의 `X-Forgejo-Delivery`는 선택이고, scan path도 빈 delivery id로 `HandleRevision`을 호출한다. 이 경우 required stable delivery header가 빈 값이 된다.

### 해결 방법

consumer-facing delivery id를 별도 helper로 만든다. 우선순위는 `record.DeliveryID`, 없으면 `record.ID` 같은 stable event id를 사용한다. header와 payload의 `delivery_id`가 같은 consumer-facing id를 쓰는지, 또는 payload가 provider id를 별도로 유지해야 한다면 명확히 분리한다.

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

- [ ] `services/core/internal/controlplane/runtime.go`: non-empty fallback delivery id helper를 추가하고 header/payload builder에 반영한다.
- [ ] `services/core/internal/controlplane/runtime_test.go`: `TestRuntime_WebhookDeliveryFallbackDeliveryID`를 추가한다.

### 테스트 작성

`HandleRevision(..., deliveryID="", ...)`로 matching delivery를 보내고, fake consumer가 non-empty `X-Gito-Delivery`와 body `delivery_id`를 받는지 확인한다.

### 중간 검증

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

## [REVIEW_API-4] Store-Backed Webhook Failure Must Allow Retry

### 문제

store-backed path는 `ProviderDeliveries().RecordOnce`를 먼저 성공시킨 뒤 webhook POST 실패를 반환한다. provider가 같은 delivery id로 재시도하면 duplicate branch로 빠져 outbound webhook을 다시 보내지 않는다.

### 해결 방법

webhook delivery 실패 시 broadcast 실패 경로와 동일하게 `DeleteDelivery(ctx, provider, dedupeKey)`를 호출하거나, delivery 성공 전에는 provider delivery dedupe record가 확정되지 않도록 순서를 조정한다. 이 follow-up은 durable outbox/retry table을 만들지 않고, 기존 provider callback retry가 같은 delivery id로 재시도할 수 있게 보장한다.

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

- [ ] `services/core/internal/controlplane/runtime.go`: webhook delivery error 경로에서 provider delivery dedupe record를 rollback한다.
- [ ] `services/core/internal/controlplane/runtime_test.go`: `TestRuntime_WebhookDeliveryFailureAllowsRetry`를 추가한다.

### 테스트 작성

첫 consumer request는 500을 반환하게 하고 같은 provider delivery id로 두 번째 `HandleRevision`을 호출한다. 두 번째 consumer request가 실제로 다시 발생하고 성공해야 하며, duplicate response로 조기 종료되면 실패해야 한다.

### 중간 검증

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

## 수정 파일 요약

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

## 최종 검증

```bash
cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1
cd services/core && go test -run 'TestWebhookDeliveryBranchUpdated|TestRuntime_WebhookDeliveryWithSecret|TestRuntime_WebhookDeliveryUnsignedSecretRefFails|TestRuntime_WebhookDeliveryFallbackDeliveryID|TestRuntime_WebhookDeliveryFailureAllowsRetry' ./internal/controlplane/ -v -count=1
cd services/core && go test ./...
```

Expected: all commands exit 0, and signature tests validate HMAC-SHA256 rather than `SHA256(secret + body)`.

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