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

# Plan - WEBHOOK_PAYLOAD

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

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

## 배경

현재 Plane webhook receiver는 signature 검증 뒤 payload를 로그로만 남기고 `202 Accepted`를 반환한다. assignee update live payload fixture는 확보됐지만 create/state-change 후보와 provider-neutral ref 정규화가 아직 없다. 이 작업은 webhook을 실행 파이프라인에 연결하기 전, 입력 payload shape와 normalize boundary를 먼저 고정한다.

## 사용자 리뷰 요청 흐름

구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 기록한다. 직접 사용자 프롬프트는 금지하며, code-review가 USER_REVIEW 작성 여부를 판단한다.

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

## 분석 결과

### 읽은 파일

- `agent-test/local/rules.md`
- `agent-test/local/core-smoke.md`
- `agent-test/local/contracts-smoke.md`
- `agent-test/plane-dev.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md`
- `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md`
- `agent-ops/rules/project/domain/core/rules.md`
- `services/core/internal/http/plane_webhook.go`
- `services/core/internal/http/plane_webhook_test.go`
- `services/core/internal/adapters/plane/client.go`
- `services/core/internal/adapters/plane/client_test.go`
- `services/core/internal/workitempipeline/service.go`
- `services/core/internal/workitempipeline/service_test.go`

### 테스트 환경 규칙

- `test_env=local`로 적용한다.
- `agent-test/local/rules.md`가 존재하고 읽혔다.
- core 변경이므로 `agent-test/local/core-smoke.md`를 적용한다. 최종 core 검증은 원격 기본 checkout `toki@toki-labs.com:$HOME/agent-work/nomadcode`에서 `zsh -lc "cd ~/agent-work/nomadcode/services/core && go test ./..."`를 실행한다.
- contracts 파일은 수정하지 않으므로 `agent-test/local/contracts-smoke.md`의 contracts 명령은 fallback 참고만 한다.
- `<확인 필요>` 값은 없다.

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

- 현재 `services/core/internal/http/plane_webhook_test.go:14`는 synthetic signed payload accepted만 확인하고, `:32`는 redacted assignee fixture accepted만 확인한다.
- create/state-change 후보 payload, 누락 필드, stale event가 normalize 단계에서 side effect 없이 정리되는지는 테스트가 없다.
- `services/core/internal/adapters/plane/client.go:120`의 fetch 기반 source-of-truth 경계는 기존 client tests로 일부 보장되지만 webhook payload normalize와 직접 연결된 테스트는 없다.

### 심볼 참조

- renamed/removed symbol 없음.

### 분할 판단

- split decision policy를 먼저 평가했다.
- 공유 task group: `m-plane-work-item-webhook-intake`
- sibling plan:
  - `01_payload_normalization`: payload 샘플과 normalize foundation. 선행 없음.
  - `02+01_trigger_dispatch`: normalized ref를 받아 기존 pipeline에 연결. `01` 완료 필요.
  - `03+02_idempotency`: dispatch 뒤 duplicate/self-retry 저장소 경계. `02` 완료 필요.
  - `04+01,02,03_live_smoke`: 원격 배포와 live smoke 증거. `01`, `02`, `03` 완료 필요.
- 이 subtask는 선행 predecessor가 없다.

### 범위 결정 근거

- 이 작업은 payload shape, fixture, normalize helper까지만 다룬다.
- `ReceivePlaneWebhook`에서 task 생성 호출을 시작하지 않는다. 부작용은 `02+01_trigger_dispatch`에서 다룬다.
- DB uniqueness, duplicate retry, self actor storage 정책은 `03+02_idempotency`로 남긴다.
- live Plane task 생성/authoring run 증거는 `04+01,02,03_live_smoke`로 남긴다.

### 빌드 등급

- `local-G06`: auth 수신기는 이미 있고, 이번 변경은 bounded parser/fixture/test 중심이다. 외부 payload 불확실성은 redacted fixture와 negative tests로 containment 가능하다.

## 구현 체크리스트

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

## [WEBHOOK_PAYLOAD-1] Normalize Helper

### 문제

`services/core/internal/http/plane_webhook.go:16`의 `planeWebhookPayload`는 event/action/webhook/workspace/data/activity만 담고, `ReceivePlaneWebhook`은 `:66`에서 로그만 남긴 뒤 `:84`에서 accepted를 반환한다. 다음 dispatch 단계가 사용할 provider-neutral `workitem.Ref`, actor, changed field boundary가 없다.

### 해결 방법

Before:

```go
// services/core/internal/http/plane_webhook.go:16
type planeWebhookPayload struct {
	Event       string          `json:"event"`
	Action      string          `json:"action"`
	WebhookID   string          `json:"webhook_id"`
	WorkspaceID string          `json:"workspace_id"`
	Data        json.RawMessage `json:"data"`
	Activity    struct {
		Field string `json:"field"`
	} `json:"activity"`
}
```

After:

```go
type planeWebhookPayload struct { /* existing fields plus normalized data support */ }

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

func normalizePlaneWebhookPayload(payload planeWebhookPayload) (planeWebhookWorkItemEvent, bool, error) {
	// accept only issue/work-item events; return ok=false for irrelevant events
	// extract workspace slug/id, project id, work item id from payload.Data
}
```

Use `workitem.NormalizeRef` before returning. Treat webhook payload as a wakeup hint only; do not read current state/assignees from payload as truth.

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

- [ ] `services/core/internal/http/plane_webhook.go`: add normalize structs/helpers and safe error handling.
- [ ] `services/core/internal/http/plane_webhook.go`: keep logs secret-free and include only ids/action/change field.

### 테스트 작성

- Write tests in `services/core/internal/http/plane_webhook_test.go`.
- Test names:
  - `TestNormalizePlaneWebhookPayloadBuildsWorkItemRef`
  - `TestNormalizePlaneWebhookPayloadIgnoresIrrelevantEvent`
  - `TestNormalizePlaneWebhookPayloadRejectsMissingWorkItemID`

### 중간 검증

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

Expected: package passes.

## [WEBHOOK_PAYLOAD-2] Fixtures And Contract Candidates

### 문제

`services/core/internal/http/testdata/plane_webhook_issue_assignees_updated.json` covers one assignee update. Milestone `payload-sample` also asks for create/update/state-change candidates.

### 해결 방법

Collect dev Plane `WebhookLog.request_body` for create and state-change if available on the remote Plane DB. If live logs are unavailable, add synthetic contract candidates under `services/core/internal/http/testdata/` based on the observed issue event envelope and clearly mark them as candidates in a tracked note without secrets.

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

- [ ] `services/core/internal/http/testdata/`: add redacted create/state fixture or candidate JSON files.
- [ ] `agent-test/plane-dev.md`: record non-secret fixture provenance and ids only.
- [ ] `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md`: update work context with fixture coverage.

### 테스트 작성

- Fixture tests are required because payload shape is the core behavior.
- Extend `TestReceivePlaneWebhookAcceptsRedactedPlaneFixture` or add table tests for each fixture file.

### 중간 검증

```bash
rg --sort path -n "secret|token|password|email|avatar|profile" services/core/internal/http/testdata agent-test/plane-dev.md
```

Expected: no raw secret/token/password and no unredacted PII appears in tracked fixture/docs.

## [WEBHOOK_PAYLOAD-3] Normalize Test Coverage

### 문제

Existing tests at `services/core/internal/http/plane_webhook_test.go:14` and `:32` assert accepted responses, but no tests prove ref extraction or ignored event semantics.

### 해결 방법

Add focused unit tests for normalize helper and keep handler tests for signature/route behavior. The normalize tests should not require DB, Plane API, SOPS, or remote services.

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

- [ ] `services/core/internal/http/plane_webhook_test.go`: add table-driven normalize tests.
- [ ] Keep existing signature tests intact.

### 테스트 작성

- Required. This is a new parsing boundary.

### 중간 검증

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

Expected: package passes without cache-only output.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `services/core/internal/http/plane_webhook.go` | WEBHOOK_PAYLOAD-1 |
| `services/core/internal/http/plane_webhook_test.go` | WEBHOOK_PAYLOAD-1, WEBHOOK_PAYLOAD-2, WEBHOOK_PAYLOAD-3 |
| `services/core/internal/http/testdata/*.json` | WEBHOOK_PAYLOAD-2 |
| `agent-test/plane-dev.md` | WEBHOOK_PAYLOAD-2 |
| `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md` | WEBHOOK_PAYLOAD-2 |

## 최종 검증

```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 ./internal/adapters/plane ./internal/workitempipeline
rg --sort path -n "secret|token|password|email|avatar|profile" services/core/internal/http/testdata agent-test/plane-dev.md
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: go tests pass; secret/PII search has no raw secret/token/password and only intentional redacted words if any; remote core test passes.

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