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

# Plan - 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 차단, 일반 범위 변경, 검증 공백은 사용자 리뷰가 아니라 검증 결과나 일반 follow-up으로 남긴다.

## 배경

`subscription` 등록 표면은 현재 작업 패스에서 `/api/webhook-subscriptions`와 in-memory registry로 추가되었다. 남은 delivery slice는 watched `branch.updated` record가 matching subscription으로 HTTP POST되는 경로와 NomadCode-style generic wakeup 검증이다. 이 plan은 PostgreSQL retry/outbox 상태를 만들지 않고, deterministic fake consumer test로 S02/S03만 완료 대상으로 삼는다.

## 사용자 리뷰 요청 흐름

구현 중 직접 사용자에게 묻지 않는다. 선택된 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

## 분석 결과

### 읽은 파일

- `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/controlplane/binary_interop_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/http_invoker.go`
- `services/core/internal/worker/http_invoker_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 ./...`가 필수다.
- SDD S02 지정 검증은 `cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1`이다.
- `contracts-smoke`, `workspace-ops-smoke`도 읽었지만 이 plan의 구현 파일은 `services/core/**`와 `agent-task/**`다. 계약 문서 자체는 수정하지 않는다.

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

- `subscription` create/list는 `TestWebhookSubscriptionRegistersFilterWithoutLeakingSecretRef`와 `TestWebhookSubscriptionValidatesTargetAndEvents`로 현재 커버된다.
- `branch.updated` event 생성은 `TestForgejoPushCreatesWatchedBranchEvent`와 runtime tests로 커버된다.
- HTTP consumer delivery, `X-Gito-Event`, `X-Gito-Delivery`, signature, NomadCode-neutral payload는 아직 테스트가 없다. 이 plan에서 `TestWebhookDeliveryBranchUpdated`를 추가한다.
- Retry/backoff/failed state는 이 plan에서 제외하고 `02+01_retry_delivery_store`가 다룬다.

### 심볼 참조

- renamed/removed symbol: none.

### 분할 판단

- split policy 평가 결과, delivery dispatch와 retry persistence는 상태 저장/스키마 위험이 달라 분리한다.
- shared task group: `agent-task/m-webhook-revision-reconcile`.
- `01_http_delivery`: no runtime predecessor. HTTP dispatch와 NomadCode-style fake consumer 검증.
- `02+01_retry_delivery_store`: depends on `01_http_delivery`; predecessor `01`은 아직 `complete.log`가 없으므로 이 plan 완료 전 구현하지 않는다.

### 범위 결정 근거

- `subscription`은 현재 working tree의 small-task 구현으로 처리되었으므로 이 plan의 Roadmap Targets에 넣지 않는다.
- PostgreSQL delivery attempts, retry/backoff, failed state, worker scheduling은 `02+01_retry_delivery_store`로 제외한다.
- provider polling, reconcile dry-run/apply, Git source dedupe는 delivery epic 밖 작업이므로 제외한다.
- external proto-socket consumer delivery는 SDD 비목표이므로 추가하지 않는다.

### 빌드 등급

- build/review: `local-G05`. Public REST/event behavior지만 변경 범위가 controlplane runtime/router/test로 좁고 `httptest`로 deterministic 검증 가능하다.

## 구현 체크리스트

- [ ] Runtime에 matching webhook subscription을 찾아 HTTP POST delivery를 수행하는 경로를 추가하고, `deliver` 검증인 `TestWebhookDeliveryBranchUpdated`를 작성한다.
- [ ] secret resolver 또는 package-private test hook으로 `secret_ref`를 raw secret 저장 없이 HMAC 서명에 연결하고, 응답/로그에 secret 값이 노출되지 않게 한다.
- [ ] fake NomadCode consumer가 base `branch.updated` payload만으로 wakeup mapping 가능한지 검증하고, NomadCode 전용 필수 field가 없음을 assertion한다.
- [ ] `cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1`를 실행한다.
- [ ] `cd services/core && go test ./...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## [API-1] HTTP Branch Updated Delivery

### 문제

`router.go:47`에 `/api/webhook-subscriptions` 등록 endpoint가 있고 `runtime.go:155`에 subscription registry가 있지만, `runtime.go:232`의 `HandleRevision`은 matching subscription으로 HTTP delivery를 보내지 않는다.

Before:

```go
// services/core/internal/controlplane/runtime.go:252
r.mu.Lock()
matched := r.matchesLocked(provider, revision)
if matched {
	r.records = append(r.records, record)
}
r.mu.Unlock()

if !matched {
	return record, false, nil
}
if r.broadcaster != nil {
	if err := r.broadcaster.BroadcastEnvelope(ctx, branchUpdatedEnvelope(record)); err != nil {
		return record, true, err
	}
}
return record, true, nil
```

### 해결 방법

`Runtime`에 webhook delivery dependencies를 추가한다. 기본 HTTP client는 timeout이 있는 `http.Client`를 사용하고, 테스트는 package-private setter 또는 constructor option으로 fake secret resolver/client를 주입한다. Matching rule은 subscription `events`가 record type을 포함하고, `repo_id`/`branch`가 비어 있거나 record와 같을 때만 허용한다. Delivery body는 계약의 base payload만 사용하고 header는 `X-Gito-Event`, `X-Gito-Delivery`, `Content-Type: application/json`, resolved secret이 있을 때 `X-Gito-Signature`를 붙인다.

After shape:

```go
// services/core/internal/controlplane/runtime.go
type WebhookSecretResolver interface {
	ResolveWebhookSecret(ctx context.Context, secretRef string) (string, bool, error)
}

type Runtime struct {
	// existing fields...
	webhookClient *http.Client
	secretResolver WebhookSecretResolver
}

func (r *Runtime) deliverWebhooks(ctx context.Context, record EventRecord) error {
	for _, subscription := range r.matchingWebhookSubscriptions(record) {
		if err := r.postWebhook(ctx, subscription, record); err != nil {
			return err
		}
	}
	return nil
}
```

Call `deliverWebhooks` after successful branch watch match and before returning success. If delivery fails in this first slice, return an error so the Forgejo callback surfaces `500` and durable retry remains for the dependent plan.

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

- [ ] `services/core/internal/controlplane/runtime.go`: add HTTP client/secret resolver dependency, matching helper, payload builder, HMAC signer, synchronous delivery call.
- [ ] `services/core/internal/controlplane/router.go`: no route shape change unless a package-private test constructor is needed.
- [ ] `services/core/internal/controlplane/runtime_test.go`: add fake consumer/secret resolver test.
- [ ] `services/core/internal/controlplane/router_test.go`: update only if HTTP-route end-to-end coverage is easier here.

### 테스트 작성

Write `TestWebhookDeliveryBranchUpdated` in `services/core/internal/controlplane/runtime_test.go` or `router_test.go`. Assertions: fake consumer receives one POST, headers include `X-Gito-Event=branch.updated`, non-empty `X-Gito-Delivery`, optional valid `X-Gito-Signature`, body contains `id`, `type`, `provider`, `delivery_id`, `repo_id`, `branch`, `before`, `after`, `changed_files`, `observed_at`, `created_at`.

### 중간 검증

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

Expected: test exits 0 and fake consumer assertions pass.

## [API-2] NomadCode Wakeup Compatibility

### 문제

SDD S03 requires a NomadCode dev consumer to map generic `branch.updated` without NomadCode-only required fields. Current delivery code has no fake NomadCode consumer test, and the payload builder does not yet exist.

Before:

```go
// services/core/internal/controlplane/runtime.go:594
type outboxBroadcaster struct {
	store storage.OperationEventStore
}
```

### 해결 방법

Keep the delivery payload consumer-neutral. In `TestWebhookDeliveryBranchUpdated`, implement the fake consumer as a NomadCode-style wakeup handler: it accepts only `type`, `repo_id`, `branch`, `before`, `after`, `changed_files`, timestamps, and delivery headers. Add a negative assertion that no `nomadcode_*`, `plane_*`, or required consumer-specific field appears in the top-level payload.

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

- [ ] `services/core/internal/controlplane/runtime.go`: payload builder must not add consumer-specific fields.
- [ ] `services/core/internal/controlplane/runtime_test.go`: fake consumer maps generic event to wakeup and asserts no consumer-specific required fields.

### 테스트 작성

Use the same `TestWebhookDeliveryBranchUpdated` so S02 and S03 share the actual interop path. Do not write a separate mock that bypasses delivery.

### 중간 검증

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

Expected: test exits 0 and the fake consumer wakeup mapping is observed.

## 수정 파일 요약

| 파일 | 항목 |
| --- | --- |
| `services/core/internal/controlplane/runtime.go` | API-1, API-2 |
| `services/core/internal/controlplane/runtime_test.go` | API-1, API-2 |
| `services/core/internal/controlplane/router.go` | API-1 if constructor wiring is needed |
| `services/core/internal/controlplane/router_test.go` | API-1 if route-level test is chosen |

## 최종 검증

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

Expected: both commands exit 0. Cached output is acceptable for the full package smoke; the SDD-specific command uses `-count=1`.

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