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

# Plan - REVIEW_REVIEW_WEBHOOK_PAYLOAD

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

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

## 배경

G07은 relevant Plane work item event에서 workspace/project/id 누락을 reject하도록 고쳤지만, assignee fixture의 `workspace` 값은 Plane workspace UUID이고 기존 Plane adapter/project-sync 경계는 workspace slug를 `workitem.Ref.Tenant`로 소비한다. 따라서 normalize helper가 raw workspace UUID를 `workitem.Ref.Tenant`에 넣은 채 "dispatch-compatible ref"라고 부르는 것은 아직 API contract를 만족하지 않는다.

## 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_G07_1.log`
- 이전 review: `agent-task/m-plane-work-item-webhook-intake/01_payload_normalization/code_review_local_G07_1.log`
- 판정: FAIL
- Required issue: webhook workspace id를 `workitem.Ref.Tenant`로 고정해 downstream Plane adapter/project-sync의 workspace slug 계약과 맞지 않는다.

## 범위 결정 근거

- 이 follow-up은 normalize event shape와 tests만 고친다.
- webhook dispatch, config env 추가, workspace id -> slug mapping wiring은 `02+01_trigger_dispatch`가 수행한다.
- raw webhook payload에는 workspace slug가 없으므로 이 단계에서 임의 hardcode 또는 dev-only mapping을 넣지 않는다.
- 대신 normalize result가 raw workspace id와 work item/project id를 명확히 보존하고, slug가 제공된 뒤에만 `workitem.Ref`를 만들 수 있게 작은 helper를 둔다.

## 빌드 등급

- `local-G08`: 같은 bounded parser/test 문제의 두 번째 follow-up이다. 실패 원인이 명확하지만 API contract 의미를 더 엄격히 봐야 하므로 local 등급을 올린다.

## 구현 체크리스트

- [ ] `planeWebhookWorkItemEvent`가 raw Plane identity(`WorkspaceID`, `ProjectID`, `WorkItemID`)와 actor/change/action hint를 보존하고, raw workspace UUID를 `workitem.Ref.Tenant`로 직접 저장하지 않도록 수정한다. 검증: assignee fixture normalize 결과가 workspace id와 project/work item id를 별도 필드로 가진다.
- [ ] workspace slug가 명시적으로 주어질 때만 provider-neutral `workitem.Ref`를 만드는 helper를 추가한다. 검증: helper는 blank slug/project/id를 reject하고, `"general"` slug 입력 시 `Ref.Tenant == "general"`인 Plane ref를 만든다.
- [ ] normalize/handler tests를 갱신해 missing workspace/project/id semantics, top-level workspace fallback, fixture raw identity, slug-based ref helper를 고정한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## [REVIEW_REVIEW_WEBHOOK_PAYLOAD-1] Separate Raw Workspace ID From Dispatch Ref

### 문제

`services/core/internal/http/plane_webhook.go:99`는 webhook payload의 `data.workspace` 또는 `workspace_id`를 `workitem.Ref.Tenant`에 넣는다. live/redacted fixture의 값은 workspace id(`dadf050e-cd1e-4590-bc33-672511630841`)다. 하지만 `services/core/internal/adapters/plane/client.go:259`의 `planeWorkItemRef`는 `Tenant`를 `WorkspaceSlug`로 사용해 Plane API path를 만들고, project-sync target 예시/테스트도 `Tenant == "general"` 같은 slug를 쓴다.

### 해결 방법

Before:

```go
type planeWebhookWorkItemEvent struct {
	Ref         workitem.Ref
	ActorID     string
	ChangeField string
	Action      string
}
```

After:

```go
type planeWebhookWorkItemEvent struct {
	WorkspaceID string
	ProjectID   string
	WorkItemID  string
	ActorID     string
	ChangeField string
	Action      string
}

func (e planeWebhookWorkItemEvent) WorkItemRef(workspaceSlug string) (workitem.Ref, error) {
	return workitem.NormalizeRef(workitem.Ref{
		Provider: planeProvider,
		Tenant:   strings.TrimSpace(workspaceSlug),
		Project:  strings.TrimSpace(e.ProjectID),
		ID:       strings.TrimSpace(e.WorkItemID),
	})
}
```

`normalizePlaneWebhookPayload`는 relevant event에서 `WorkspaceID`, `ProjectID`, `WorkItemID`가 비면 `ok=true, err!=nil`을 반환한다. `ReceivePlaneWebhook` 로그는 `event.WorkItemID`, `event.ProjectID`, `event.WorkspaceID`를 사용한다. 이 단계에서는 dispatch를 호출하지 않으므로 slug 매핑은 하지 않는다.

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

- [ ] `services/core/internal/http/plane_webhook.go`: normalized event shape를 raw identity + hint로 바꾸고, slug 기반 ref helper를 추가한다.
- [ ] `services/core/internal/http/plane_webhook.go`: handler logging이 `event.Ref.*` 대신 raw identity fields를 사용하도록 수정한다.
- [ ] `services/core/internal/http/plane_webhook_test.go`: fixture assertion을 `WorkspaceID` raw value와 `WorkItemRef("general")` 결과로 나눠 검증한다.

### 테스트 작성

- Required. 이번 FAIL은 API contract test 공백이 원인이다.
- 권장 테스트 이름:
  - `TestPlaneWebhookWorkItemEventBuildsRefWithWorkspaceSlug`
  - 기존 `TestNormalizePlaneWebhookPayloadAcceptsAssigneeFixture`에서 `WorkspaceID`, `ProjectID`, `WorkItemID` assertion
  - missing project/workspace/id reject tests 유지

### 중간 검증

```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_REVIEW_WEBHOOK_PAYLOAD-1 |
| `services/core/internal/http/plane_webhook_test.go` | REVIEW_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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
