<!-- task=m-plane-work-item-webhook-intake/03+02_idempotency plan=0 tag=WEBHOOK_IDEMPOTENCY -->

# Plan - WEBHOOK_IDEMPOTENCY

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

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

## 배경

Plane은 webhook retry나 duplicate delivery를 보낼 수 있고, NomadCode self mutation event도 다시 들어올 수 있다. 현재 task insert는 external provider/id를 저장하지만 unique/idempotent guard가 없다. 이 작업은 같은 Plane work item이 여러 번 들어와도 creation task와 authoring run이 중복되지 않도록 저장소 경계를 고정한다.

## 사용자 리뷰 요청 흐름

구현 중 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:
  - `loop-idempotency`: duplicate webhook, provider retry, NomadCode self actor event를 중복 creation task 없이 처리한다.
- 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/storage/store.go`
- `services/core/internal/workflow/service.go`
- `services/core/internal/workflow/service_test.go`
- `services/core/internal/db/tasks.sql.go`
- `services/core/queries/tasks.sql`
- `services/core/migrations/00001_create_tasks.sql`
- `services/core/migrations/00002_add_task_external_refs.sql`
- `services/core/sqlc.yaml`
- `services/core/internal/workitempipeline/service.go`
- `services/core/internal/workitempipeline/service_test.go`
- `services/core/internal/http/plane_webhook.go`
- `services/core/internal/http/plane_webhook_test.go`

### 테스트 환경 규칙

- `test_env=local`로 적용한다.
- `agent-test/local/rules.md`와 `agent-test/local/core-smoke.md`를 읽었다.
- core storage/query/generated code 변경이므로 원격 기본 checkout에서 `zsh -lc "cd ~/agent-work/nomadcode/services/core && go test ./..."`를 실행한다.
- 로컬에 `sqlc` binary는 없었다. generated db code가 필요하면 `cd services/core && go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate`를 사용한다.
- `<확인 필요>` 값은 없다.

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

- `services/core/queries/tasks.sql:1`의 `CreateTask`는 plain insert라 duplicate external ref를 막지 않는다.
- `services/core/migrations/00002_add_task_external_refs.sql:2`는 external columns만 추가하고 unique index가 없다.
- `services/core/internal/workflow/service.go:39`는 normalized external ref를 저장소로 넘기지만 idempotent result semantics를 정의하지 않는다.
- `services/core/internal/workitempipeline/service_test.go:522`는 self actor ignored가 side effect를 만들지 않음을 확인하지만 duplicate webhook retry는 확인하지 않는다.

### 심볼 참조

- renamed/removed symbol 없음. `CreateTask` semantics 변경 또는 새 query 추가 시 call site는 `workflow.Service.CreateTask`와 `workitempipeline.TaskCreator` 경계다.

### 분할 판단

- split decision policy를 먼저 평가했다.
- shared task group: `m-plane-work-item-webhook-intake`
- 이 subtask directory `03+02_idempotency`는 predecessor `02`를 요구한다.
- predecessor `02` 상태: `agent-task/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/complete.log` 없음. 구현 시작 전 `02+01_trigger_dispatch` PASS/archive 또는 active complete.log가 필요하다.

### 범위 결정 근거

- 이 작업은 duplicate task prevention까지 다룬다.
- Payload parsing과 handler dispatch는 선행 task 산출물로 간주한다.
- UI/live smoke evidence는 `04+01,02,03_live_smoke`에서 다룬다.
- Historical data backfill은 필요할 때 migration의 duplicate-precheck로만 다루고, 사용자 데이터 수동 정리는 이 plan 범위 밖이다. migration이 기존 duplicate로 실패한다면 review stub의 사용자 리뷰 요청에 정확한 SQL evidence를 남긴다.

### 빌드 등급

- `cloud-G06`: DB unique constraint, generated SQL, workflow idempotent semantics가 얽혀 있어 저장소/protocol 판단 리스크가 있다.

## 의존 관계 및 구현 순서

- `02+01_trigger_dispatch`가 `complete.log`를 생성한 뒤 시작한다.
- 이 plan은 directory name의 predecessor `+02`만 의존한다.

## 구현 체크리스트

- [ ] Plane-origin task idempotency key를 external provider/id로 고정하고 DB unique/index 또는 upsert semantics를 추가한다.
- [ ] duplicate webhook/provider retry가 기존 task를 반환하거나 ignored 처리되어 새 task, workspace provision, slot reservation을 만들지 않도록 workflow/workitempipeline 경계를 조정한다. 검증: 같은 provider/work item/revision 재처리와 self actor 입력이 task 중복 생성으로 이어지지 않는다.
- [ ] storage/workflow/workitempipeline/http tests를 추가해 duplicate external ref, duplicate webhook dispatch, self actor ignored를 검증한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## [WEBHOOK_IDEMPOTENCY-1] Storage Idempotency Boundary

### 문제

`services/core/queries/tasks.sql:1`은 plain insert이고 `services/core/migrations/00002_add_task_external_refs.sql:2`에는 unique index가 없다. 같은 `external_provider=plane`, `external_id=<work item id>`가 여러 번 들어오면 task row가 중복될 수 있다.

### 해결 방법

Before:

```sql
-- services/core/queries/tasks.sql:1
INSERT INTO tasks (title, source, payload, metadata, external_provider, external_id, external_url, external_metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, title, source, status, payload, result, error, created_at, updated_at, external_provider, external_id, external_url, external_metadata, metadata;
```

After:

```sql
CREATE UNIQUE INDEX IF NOT EXISTS ux_tasks_external_ref
ON tasks (external_provider, external_id)
WHERE external_provider IS NOT NULL AND external_id IS NOT NULL;
```

Use an upsert/no-op-returning query for external refs:

```sql
INSERT INTO tasks (...)
VALUES (...)
ON CONFLICT (external_provider, external_id)
WHERE external_provider IS NOT NULL AND external_id IS NOT NULL
DO UPDATE SET updated_at = tasks.updated_at
RETURNING ...;
```

If changing global `CreateTask` is too broad after reading current call sites, add a separate generated query and keep plain internal task creation unchanged. Record the choice in review.

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

- [ ] `services/core/migrations/00008_add_task_external_ref_unique.sql`: add partial unique index.
- [ ] `services/core/queries/tasks.sql`: add idempotent create query or adjust `CreateTask`.
- [ ] `services/core/internal/db/tasks.sql.go`: regenerate with sqlc.
- [ ] `services/core/internal/storage/store.go`: expose idempotent create semantics.

### 테스트 작성

- Required storage/workflow tests.
- If no DB integration test harness exists, use generated SQL string assertions plus workflow fake store semantics, and record the limitation.

### 중간 검증

```bash
cd services/core && go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate
```

Expected: generated db files update cleanly.

## [WEBHOOK_IDEMPOTENCY-2] Workflow And Pipeline Semantics

### 문제

`services/core/internal/workflow/service.go:64` always calls `store.CreateTask`; `services/core/internal/workitempipeline/service.go:113` provisions workspace and reserves slot before task insert. If duplicate detection happens only at insert time, duplicate webhook can still reserve a slot before returning existing task.

### 해결 방법

Prefer checking idempotency before workspace provision/slot reservation when external provider/id is present. Options:

1. Add a narrow store lookup by external ref and let `workitempipeline` return existing task before provision.
2. Add an idempotent reservation-safe `TaskCreator` method only for work item creation.

Choose the smallest change that prevents duplicate slot reservation. Do not rely solely on post-provision insert conflict unless tests prove no duplicate side effects.

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

- [ ] `services/core/internal/workflow/service.go`: add or route idempotent external-ref lookup/create semantics.
- [ ] `services/core/internal/workitempipeline/service.go`: avoid workspace/slot side effects for already-created work item task.
- [ ] `services/core/internal/workitempipeline/service_test.go`: assert duplicate path does not call `EnsureProjectWorkspace` or `ReserveWorkspaceSlot`.

### 테스트 작성

- Required:
  - `TestCreateTaskFromWorkItemReturnsExistingExternalTaskWithoutProvision`
  - `TestCreateTaskFromWorkItemDuplicateExternalRefDoesNotReserveSlot`

### 중간 검증

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

Expected: packages pass.

## [WEBHOOK_IDEMPOTENCY-3] Webhook Duplicate/Self Tests

### 문제

Self actor guard exists in `services/core/internal/workitempipeline/service.go:162`, but webhook handler must pass actor/self actor consistently. Duplicate webhook handler tests do not exist yet.

### 해결 방법

Add handler-level tests that call `ReceivePlaneWebhook` twice with same work item event. The fake creator should prove the second call does not create duplicate side effects once idempotency boundary exists. Keep self actor ignored returning 202.

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

- [ ] `services/core/internal/http/plane_webhook_test.go`: duplicate signed event test.
- [ ] `services/core/internal/http/plane_webhook_test.go`: self actor ignored test if not covered by dispatch task.

### 테스트 작성

- Required because webhook retry is the milestone task's behavioral surface.

### 중간 검증

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

Expected: packages pass.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `services/core/migrations/00008_add_task_external_ref_unique.sql` | WEBHOOK_IDEMPOTENCY-1 |
| `services/core/queries/tasks.sql` | WEBHOOK_IDEMPOTENCY-1 |
| `services/core/internal/db/tasks.sql.go` | WEBHOOK_IDEMPOTENCY-1 |
| `services/core/internal/storage/store.go` | WEBHOOK_IDEMPOTENCY-1 |
| `services/core/internal/workflow/service.go` | WEBHOOK_IDEMPOTENCY-2 |
| `services/core/internal/workflow/service_test.go` | WEBHOOK_IDEMPOTENCY-1, WEBHOOK_IDEMPOTENCY-2 |
| `services/core/internal/workitempipeline/service.go` | WEBHOOK_IDEMPOTENCY-2 |
| `services/core/internal/workitempipeline/service_test.go` | WEBHOOK_IDEMPOTENCY-2 |
| `services/core/internal/http/plane_webhook_test.go` | WEBHOOK_IDEMPOTENCY-3 |

## 최종 검증

```bash
cd services/core && go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate
cd services/core && gofmt -w internal/db/tasks.sql.go internal/storage/store.go internal/workflow/service.go internal/workflow/service_test.go internal/workitempipeline/service.go internal/workitempipeline/service_test.go internal/http/plane_webhook_test.go
cd services/core && go test -count=1 ./internal/storage ./internal/workflow ./internal/workitempipeline ./internal/http
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: sqlc generation succeeds, focused tests pass, remote full core tests pass.

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