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

# Code Review Reference - WEBHOOK_PAYLOAD

> **[IMPLEMENTING AGENT - READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation.
> Finalization is review-agent-only.

## 개요

date=2026-06-14
task=m-plane-work-item-webhook-intake/01_payload_normalization, plan=0, tag=WEBHOOK_PAYLOAD

## 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

## 이 파일을 읽는 리뷰 에이전트에게

> **[REVIEW AGENT ONLY]** 구현 결과를 실제 소스와 대조하고, PASS이면 code-review 절차에 따라 log/complete/archive를 처리한다. 구현 에이전트는 이 섹션을 실행하지 않는다.

## 구현 항목별 완료 여부

| 항목 | 완료 여부 |
|------|---------|
| [WEBHOOK_PAYLOAD-1] Normalize Helper | [x] |
| [WEBHOOK_PAYLOAD-2] Fixtures And Contract Candidates | [x] |
| [WEBHOOK_PAYLOAD-3] Normalize Test Coverage | [x] |

## 구현 체크리스트

- [x] Plane issue create/update/state/assignee payload에서 provider-neutral ref, actor, change hint를 추출하는 normalize helper를 추가한다. 검증: raw secret/token 없이 work item create/update, state change, assignee change 후보 payload shape를 확인한다.
- [x] redacted fixture 또는 문서화된 contract candidate를 추가하고 PII/secret 원문이 포함되지 않음을 확인한다.
- [x] normalize 단위 테스트를 추가해 missing field, stale/irrelevant field, assignee update fixture accepted 경로를 검증한다. 검증: payload 필드 누락 또는 stale event가 있어도 fetch 기반 trigger 판정으로 수렴할 수 있는 ref/hint만 만든다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## 코드리뷰 전용 체크리스트

> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.

- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] active `CODE_REVIEW-*-G??.md`와 `PLAN-*-G??.md`를 `.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] FAIL 후속으로 `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] PASS이면 `complete.log`를 작성하고 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-plane-work-item-webhook-intake/01_payload_normalization/`로 이동한다.
- [ ] PASS이고 task group이 `m-plane-work-item-webhook-intake`이므로 완료 이벤트 메타데이터를 보고한다. roadmap 수정은 런타임 책임이다.

## 계획 대비 변경 사항

- 계획의 `planeWebhookPayload`는 "existing fields plus normalized data support"로만 표기되어 있었다. 실제로는 기존 필드를 유지하면서 `Activity.Actor`(`planeWebhookActor`)만 추가했고, data 파싱은 별도 `planeWebhookData` 구조체(`id`/`project`/`workspace`)로 분리했다. 이유: payload state(state/assignees/labels/body)를 truth로 흡수하지 않도록 식별 필드만 좁게 디코딩하기 위함이다.
- 기존 `stringFieldFromRawJSON` helper는 normalize helper가 같은 역할을 typed로 대체하므로 제거했다. 핸들러 로그는 `event.Ref.ID`/`Project`/`Tenant`/`ChangeField`/`ActorID`만 남긴다.
- workspace 식별자는 `data.workspace`를 우선하고 top-level `workspace_id`로 fallback한다. 둘 다 UUID이며, `FetchWorkItem`이 요구하는 workspace slug 변환은 이 단계 범위가 아니라 `02+01_trigger_dispatch`의 fetch 경로 책임으로 남겼다. 이 단계는 ref/hint만 만든다.
- 계획에 없던 보조 테스트 `TestNormalizePlaneWebhookPayloadFallsBackToWorkspaceID`를 추가해 workspace 우선순위/fallback 경계를 고정했다.

## 주요 설계 결정

- `normalizePlaneWebhookPayload(payload) (event, ok, error)` 3값 시그니처: `ok=false`는 work item이 아닌 이벤트(project/cycle/module/빈 event)로 side effect 없이 ignore, `ok=true && err!=nil`은 work item 이벤트이지만 ref 필수 필드(work item id) 누락이다. 핸들러는 두 경우 모두 202 accepted를 반환하되 로그 `reason`을 `irrelevant_event` / `missing_work_item_ref`로 구분한다.
- payload는 wakeup hint로만 취급한다. `state`, `assignees`, `labels`, `description`은 디코딩하지 않으며 ref(`provider=plane`, `tenant`=workspace, `project`, `id`)와 actor/change hint만 추출한다. 최종 trigger 판정은 후속 단계의 Plane API 재조회가 source of truth다 (milestone 표준선과 일치).
- `workitem.NormalizeRef`를 통과시켜 provider/id 필수성과 metadata 기본값을 기존 work item 계약과 동일하게 보장한다.
- create/state fixture는 live WebhookLog를 별도로 확보하지 않아 관찰된 issue envelope 기반 synthetic candidate로 추가하고 `agent-test/plane-dev.md`에 candidate임을 명시했다. PII 필드(email/avatar/profile)는 새 fixture에서 아예 생략해 secret/PII scan 노이즈를 만들지 않았다.

## 사용자 리뷰 요청

_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._

- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음

## 리뷰어를 위한 체크포인트

- Payload normalize가 source-of-truth 판정을 하지 않고 ref/hint만 만든다.
- Fixture/docs에 raw secret/token/PII가 없다.
- Missing/irrelevant payload가 side effect 없이 처리된다.

## 검증 결과

_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._

### WEBHOOK_PAYLOAD-1 중간 검증

```bash
$ cd services/core && go test -count=1 ./internal/http
ok  	github.com/nomadcode/nomadcode-core/internal/http	0.005s
```

### WEBHOOK_PAYLOAD-2 중간 검증

```bash
$ rg --sort path -n "secret|token|password|email|avatar|profile" services/core/internal/http/testdata agent-test/plane-dev.md
services/core/internal/http/testdata/plane_webhook_issue_assignees_updated.json:20:        "email": "redacted@example.invalid",
services/core/internal/http/testdata/plane_webhook_issue_assignees_updated.json:21:        "avatar": "",
services/core/internal/http/testdata/plane_webhook_issue_assignees_updated.json:22:        "avatar_url": null,
services/core/internal/http/testdata/plane_webhook_issue_assignees_updated.json:69:      "email": "redacted@example.invalid",
services/core/internal/http/testdata/plane_webhook_issue_assignees_updated.json:70:      "avatar": "redacted",
services/core/internal/http/testdata/plane_webhook_issue_assignees_updated.json:71:      "avatar_url": "redacted",
agent-test/plane-dev.md:3:이 문서는 dev Plane 연동 테스트에서 반복 사용하는 non-secret 식별자를 기록한다.
agent-test/plane-dev.md:4:API token, webhook secret, password 같은 secret 원문은 이 파일에 쓰지 않는다.
agent-test/plane-dev.md:30:- Webhook secret storage: SOPS encrypted `secrets/nomadcode.dev.sops.yaml` key `PLANE_WEBHOOK_SECRET`
agent-test/plane-dev.md:35:`services/core/internal/http/testdata/` 아래 fixture로 payload shape를 고정한다. 모든 fixture는 PII/secret redaction 상태이며 work item id는 placeholder다.
# 기존 assignee fixture의 redacted marker와 문서의 keyword 언급만 매칭됨. 원문 secret/token/password 없음.
# 새 create/state fixture는 PII 필드를 생략해 매칭 0건이다.
```

### WEBHOOK_PAYLOAD-3 중간 검증

```bash
$ cd services/core && go test -count=1 ./internal/http
ok  	github.com/nomadcode/nomadcode-core/internal/http	0.006s
```

### 최종 검증

```bash
$ cd services/core && gofmt -w internal/http/plane_webhook.go internal/http/plane_webhook_test.go
(no output)
$ cd services/core && go test -count=1 ./internal/http ./internal/adapters/plane ./internal/workitempipeline
ok  	github.com/nomadcode/nomadcode-core/internal/http	0.006s
ok  	github.com/nomadcode/nomadcode-core/internal/adapters/plane	0.007s
ok  	github.com/nomadcode/nomadcode-core/internal/workitempipeline	0.003s
$ rg --sort path -n "secret|token|password|email|avatar|profile" services/core/internal/http/testdata agent-test/plane-dev.md
# WEBHOOK_PAYLOAD-2 중간 검증과 동일. 원문 secret/token/password 없음.
$ 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 -'
(sync ok)
$ ssh toki@toki-labs.com 'zsh -lc "cd ~/agent-work/nomadcode/services/core && go test -count=1 ./..."'
ok  	github.com/nomadcode/nomadcode-core/cmd/plane-smoke	0.465s
?   	github.com/nomadcode/nomadcode-core/cmd/server	[no test files]
ok  	github.com/nomadcode/nomadcode-core/internal/adapters/a2a	1.691s
ok  	github.com/nomadcode/nomadcode-core/internal/adapters/jira	0.867s
ok  	github.com/nomadcode/nomadcode-core/internal/adapters/mattermost	1.276s
ok  	github.com/nomadcode/nomadcode-core/internal/adapters/openai	2.052s
ok  	github.com/nomadcode/nomadcode-core/internal/adapters/plane	2.351s
ok  	github.com/nomadcode/nomadcode-core/internal/http	3.703s
ok  	github.com/nomadcode/nomadcode-core/internal/workitem	3.748s
ok  	github.com/nomadcode/nomadcode-core/internal/workitempipeline	3.718s
# 전체 패키지 통과 (생략 패키지 포함 모두 ok / no test files).
```

## 코드리뷰 결과

### 종합 판정

FAIL

### 차원별 평가

| 차원 | 평가 | 근거 |
|------|------|------|
| Correctness | Fail | normalize가 다음 Plane fetch/project binding 경로에서 필수인 ref 필드를 누락해도 성공으로 표시할 수 있다. |
| Completeness | Fail | 계획의 missing/stale field 수렴 검증이 work item id에만 걸려 있고 tenant/project 누락을 닫지 못했다. |
| Test coverage | Fail | missing project, missing workspace/tenant, dispatch-compatible ref shape를 고정하는 테스트가 없다. |
| API contract | Fail | `workitem.Ref`를 소비하는 Plane adapter/project-sync 경계는 tenant/project/id를 요구하는데 normalize 경계는 provider/id만 보장한다. |
| Code quality | Pass | 범위 내 디버그 출력, dead code, 불필요한 상태 흡수는 보이지 않는다. |
| Plan deviation | Fail | provider-neutral ref/hint만 만든다는 방향은 맞지만, fetch 기반 trigger 판정으로 넘길 수 있는 ref 완성 조건을 충족하지 못했다. |
| Verification trust | Pass | 현 checkout에서 대상 패키지 테스트, 전체 core 테스트, secret/PII grep, `git diff --check`를 재실행해 출력이 대체로 일치함을 확인했다. |

### 발견된 문제

- Required: `services/core/internal/http/plane_webhook.go:86`에서 `workspace`와 `project`를 비워 둔 채 `workitem.NormalizeRef`에 넘기면, `workitem.NormalizeRef`는 `provider/id`만 검사하므로 `ok=true`인 normalized event가 만들어진다. 하지만 다음 단계가 쓸 Plane adapter는 `tenant/project/id` 모두를 필수로 보고(`services/core/internal/adapters/plane/client.go:259`), project-sync target도 `provider/tenant/project`를 필수로 본다(`services/core/internal/projectsync/config.go:102`). 이 상태로 follow-up dispatch가 붙으면 missing project/tenant payload가 ignored가 아니라 "normalized=true" ref로 넘어가거나, 빈 project/tenant key로 binding/fetch 실패를 일으킨다. fix: relevant work item event에서는 dispatch-compatible ref tuple을 명시적으로 검증한다. 최소한 trimmed workspace/tenant와 project가 없으면 `ok=true, err!=nil`로 처리하고, tests에 missing project 및 missing workspace/tenant 케이스를 추가한다. 만약 `Tenant`에 workspace id를 유지할 의도라면 Plane adapter/project-sync가 그 key를 실제로 허용한다는 테스트나 변환 경계를 함께 고정한다.

### 다음 단계

WARN/FAIL 후속: `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.md`를 작성해 ref 필수 필드 검증과 회귀 테스트만 좁게 수정한다.
