<!-- task=m-plane-work-item-webhook-intake/01_payload_normalization plan=1 tag=REVIEW_WEBHOOK_PAYLOAD -->

# Plan - REVIEW_WEBHOOK_PAYLOAD

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

이 계획은 이전 리뷰의 Required issue 하나만 닫는다. 구현 완료 전 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 설계 결정, 검증 출력으로 반드시 채운다. 구현 중 사용자만 결정할 수 있는 외부 환경, secret, scope 충돌이 생기면 리뷰 stub의 `사용자 리뷰 요청` 섹션에 증거와 재개 조건을 기록하고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 닫을 수 있는 증거 공백은 사용자 리뷰 요청이 아니다.

## 배경

첫 리뷰는 `normalizePlaneWebhookPayload`가 relevant Plane work item event를 성공으로 정규화하면서도 다음 dispatch/fetch 경계가 요구하는 `tenant/project/id` ref tuple을 보장하지 못한다고 판정했다. `workitem.NormalizeRef`는 provider/id만 검사하므로 현재 구현은 project 또는 workspace/tenant가 비어도 `ok=true`를 반환할 수 있다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md`
- Task ids:
  - `payload-sample`: dev Plane webhook payload 샘플과 이벤트 종류를 확보하고 receiver 입력 fixture 또는 문서화된 contract candidate로 남긴다.
  - `event-normalize`: Plane webhook payload를 provider-neutral work item ref와 actor/change hint로 정규화하되, 최종 work item 상태는 Plane API 재조회 결과를 사용한다.
- Completion mode: check-on-pass

## 리뷰 입력

- 이전 plan: `agent-task/m-plane-work-item-webhook-intake/01_payload_normalization/plan_local_G06_0.log`
- 이전 review: `agent-task/m-plane-work-item-webhook-intake/01_payload_normalization/code_review_local_G06_0.log`
- 판정: FAIL
- Required issue: `services/core/internal/http/plane_webhook.go:86`에서 missing project/workspace가 dispatch-compatible ref로 실패하지 않는다.

## 범위 결정 근거

- 이 follow-up은 normalize helper와 해당 단위 테스트만 수정한다.
- webhook dispatch, DB idempotency, live smoke, Plane API 조회 실행은 sibling subtask 범위로 남긴다.
- workspace tenant 표현은 downstream이 실제로 소비할 표현으로 고정한다. workspace id를 `Tenant`로 유지한다면 Plane adapter/project-sync 경계가 그 표현을 허용한다는 테스트 또는 근거를 함께 남긴다. 불확실한 상태로 `ok=true`를 반환하지 않는다.

## 빌드 등급

- `local-G07`: 이전 local-G06 리뷰에서 명확하고 재현 가능한 correctness gap이 발견됐다. 수정 범위는 bounded parser/test이며 local review로 충분하되, ref 계약 누락을 더 꼼꼼히 확인해야 한다.

## 구현 체크리스트

- [ ] `normalizePlaneWebhookPayload`가 relevant Plane work item event에서 dispatch-compatible ref tuple(`tenant/workspace`, `project`, `id`)을 모두 검증하도록 수정한다. 검증: missing project, missing workspace/tenant, top-level workspace fallback cases가 의도한 `ok/error` semantics를 갖는다.
- [ ] normalize 단위 테스트를 보강해 missing project, missing workspace/tenant, fixture 기반 full ref의 non-empty tenant/project/id를 고정한다. 검증: downstream Plane adapter/project-sync가 요구하는 ref tuple과 불일치하지 않음을 테스트 이름 또는 assertion으로 드러낸다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## [REVIEW_WEBHOOK_PAYLOAD-1] Dispatch-Compatible Ref Validation

### 문제

`services/core/internal/http/plane_webhook.go:86`은 `workspace := firstNonEmptyTrimmed(data.Workspace, payload.WorkspaceID)`와 `strings.TrimSpace(data.Project)`를 그대로 `workitem.NormalizeRef`에 넘긴다. 그러나 `workitem.NormalizeRef`는 provider/id만 필수로 검사한다. 반면 `services/core/internal/adapters/plane/client.go`의 `planeWorkItemRef`와 `projectsync.ProviderProjectTarget.Normalize`는 tenant/project/id가 비면 실패한다.

### 해결 방법

Before:

```go
ref, err := workitem.NormalizeRef(workitem.Ref{
	Provider: planeProvider,
	Tenant:   workspace,
	Project:  strings.TrimSpace(data.Project),
	ID:       strings.TrimSpace(data.ID),
})
```

After:

```go
workspace := firstNonEmptyTrimmed(data.Workspace, payload.WorkspaceID)
project := strings.TrimSpace(data.Project)
id := strings.TrimSpace(data.ID)
if workspace == "" || project == "" || id == "" {
	return planeWebhookWorkItemEvent{}, true, workitem.ErrInvalidRef
}
ref, err := workitem.NormalizeRef(workitem.Ref{
	Provider: planeProvider,
	Tenant:   workspace,
	Project:  project,
	ID:       id,
})
```

표현 선택이 필요하면 `Tenant`는 다음 dispatch 단계가 `FetchWorkItem`과 project-sync target에 그대로 넘길 수 있는 값으로 고정한다. 이 follow-up에서 변환을 구현하지 않는다면, 최소한 현재 helper가 반환하는 값이 후속 단계의 필수 tuple을 모두 채운다는 테스트를 남긴다.

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

- [ ] `services/core/internal/http/plane_webhook.go`: missing workspace/project/id를 relevant event의 normalize error로 처리한다.
- [ ] `services/core/internal/http/plane_webhook_test.go`: missing project, missing workspace/tenant, top-level workspace fallback, fixture full ref assertions를 보강한다.

### 테스트 작성

- Required. 누락 필드가 이번 FAIL의 원인이므로 테스트 없이는 닫을 수 없다.
- 권장 테스트 이름:
  - `TestNormalizePlaneWebhookPayloadRejectsMissingProject`
  - `TestNormalizePlaneWebhookPayloadRejectsMissingWorkspace`
  - 기존 `TestNormalizePlaneWebhookPayloadFallsBackToWorkspaceID` 유지 또는 이름 보강
  - `TestNormalizePlaneWebhookPayloadAcceptsAssigneeFixture`에서 `Tenant`, `Project`, `ID` 모두 assertion

### 중간 검증

```bash
cd services/core && go test -count=1 ./internal/http
```

Expected: package passes without cache-only output.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `services/core/internal/http/plane_webhook.go` | REVIEW_WEBHOOK_PAYLOAD-1 |
| `services/core/internal/http/plane_webhook_test.go` | REVIEW_WEBHOOK_PAYLOAD-1 |

## 최종 검증

```bash
cd services/core && gofmt -w internal/http/plane_webhook.go internal/http/plane_webhook_test.go
cd services/core && go test -count=1 ./internal/http
cd services/core && go test -count=1 ./...
git diff --check
tar -C services/core -czf - . | ssh toki@toki-labs.com 'mkdir -p ~/agent-work/nomadcode/services/core && tar -C ~/agent-work/nomadcode/services/core -xzf -'
ssh toki@toki-labs.com 'zsh -lc "cd ~/agent-work/nomadcode/services/core && go test -count=1 ./..."'
```

Expected: local and remote Go tests pass; `git diff --check` has no output. Remote command output must not contain raw secret/token/password values.

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