<!-- task=m-iop-agent-run-bridge/04+03_revision_scan_event plan=3 tag=REVIEW_REVIEW_REVIEW_API -->

# Plan - REVIEW_REVIEW_REVIEW_API

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

이 계획은 구현 후 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 내용과 검증 출력으로 채우는 것까지가 작업이다. 구현 에이전트는 검증을 실행하고 active 파일을 유지한 채 리뷰 준비 상태를 보고한다. 사용자만 결정할 수 있는 정책, 외부 secret, scope 충돌로 막히면 active review stub의 `사용자 리뷰 요청` 섹션에 정확한 결정, 근거, 실행 명령, 재개 조건을 남기고 멈춘다. 구현 중 채팅으로 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 재실행이나 산출물 수집으로 해소할 수 있는 검증 공백은 사용자 리뷰 요청이 아니다. 최종 archive, `complete.log`, roadmap 갱신은 code-review/runtime 책임이다.

## 배경

cloud G07은 worker revision scan에 outbox broadcaster를 주입했지만, empty operation id로 저장한 branch event를 다시 읽는 Postgres 경로가 NULL을 처리하지 못한다. 또 scan publish/store 실패 전에 cursor와 delivery dedupe state가 먼저 기록되어 재시도에서 같은 event를 다시 publish/store할 수 없다. 이 후속 계획은 branch.updated durable publish boundary를 "저장 가능하고, 읽기 가능하며, 실패 시 재시도 가능한" 경계로 닫는다.

## 사용자 리뷰 요청 흐름

구현 중 차단은 active `CODE_REVIEW-*-G??.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 직접 사용자 프롬프트는 금지이며, code-review가 요청 타당성을 검증하고 실제 `USER_REVIEW.md` 작성을 소유한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md`
- Task ids:
  - `revision-event`: push 이후 revision scan event를 발행한다.
- Completion mode: check-on-pass

## Archive Evidence Snapshot

- Current archived plan: `agent-task/m-iop-agent-run-bridge/04+03_revision_scan_event/plan_cloud_G07_2.log`
- Current archived review: `agent-task/m-iop-agent-run-bridge/04+03_revision_scan_event/code_review_cloud_G07_2.log`
- Verdict: FAIL
- Required summary:
  - `services/core/internal/storage/postgres.go:916` stores empty `OperationID` as SQL NULL, but `services/core/internal/storage/postgres.go:1027` scans nullable `operation_id` into `string`. pgx returns `cannot scan NULL into *string`, so generic `branch.updated` outbox rows cannot be listed or marked published.
  - `services/core/internal/controlplane/runtime.go:210`, `services/core/internal/controlplane/runtime.go:223`, and `services/core/internal/controlplane/runtime.go:240` record provider delivery/cursor state before broadcaster publish/store. If outbox append fails, retry can no longer re-emit the same revision event.
- Affected files:
  - `services/core/internal/storage/postgres.go`
  - `services/core/internal/storage/postgres_internal_test.go`
  - `services/core/internal/storage/postgres_test.go`
  - `services/core/internal/controlplane/runtime.go`
  - `services/core/internal/controlplane/runtime_test.go`
  - `services/core/cmd/worker/main.go`
  - `services/core/internal/worker/runner.go`
  - `services/core/internal/worker/runner_test.go`
  - `packages/contracts/notes/gito-control-plane.md`
- Verification evidence from failed review:
  - `cd services/core && go test -count=1 ./internal/worker ./internal/controlplane` passed.
  - `cd services/core && go test ./...` passed, but storage Postgres integration tests were cached/skipped without `GITO_TEST_DATABASE_URL`.
  - `cd services/core && go test -v -run TestPostgresOperationEventStoreAppendsListsMarksPublishedAndValidates ./internal/storage` skipped with `GITO_TEST_DATABASE_URL not set`.
  - `rg --sort path -n "proto-socket|REST|gRPC|ChangeRequest|Operation|Event|agent_shell|agent-shell|iop" packages/contracts README.md docs` passed.
  - `git diff --check` passed.
- Allowed archive reread:
  - `agent-task/m-iop-agent-run-bridge/04+03_revision_scan_event/code_review_cloud_G07_2.log`
  - `agent-task/m-iop-agent-run-bridge/04+03_revision_scan_event/plan_cloud_G07_2.log`

## 분석 결과

### 읽은 파일

- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-test/local/rules.md`
- `agent-test/local/core-smoke.md`
- `agent-test/local/contracts-smoke.md`
- `agent-ops/rules/project/domain/core/rules.md`
- `agent-ops/rules/project/domain/contracts/rules.md`
- `agent-contract/index.md`
- `agent-contract/provided/gito-forgejo-branch-events-v1.md`
- `agent-task/m-iop-agent-run-bridge/04+03_revision_scan_event/PLAN-cloud-G07.md`
- `agent-task/m-iop-agent-run-bridge/04+03_revision_scan_event/CODE_REVIEW-cloud-G07.md`
- `services/core/internal/storage/postgres.go`
- `services/core/internal/storage/postgres_test.go`
- `services/core/internal/controlplane/runtime.go`
- `services/core/internal/controlplane/runtime_test.go`
- `services/core/internal/worker/runner.go`
- `services/core/internal/worker/runner_test.go`
- `services/core/cmd/worker/main.go`
- `packages/contracts/notes/gito-control-plane.md`
- `.gitignore`

### 테스트 환경 규칙

- `test_env=local`; `agent-test/local/rules.md` 있음, 읽음.
- 적용 profile: `agent-test/local/core-smoke.md`, `agent-test/local/contracts-smoke.md`.
- core 필수 명령: `cd services/core && go test ./...`.
- contracts smoke: `rg --sort path -n "proto-socket|REST|gRPC|ChangeRequest|Operation|Event|agent_shell|agent-shell|iop" packages/contracts README.md docs`.
- 이 follow-up은 DB integration 환경이 없어도 NULL scan regression을 잡는 package-internal unit test가 필요하다.
- `<확인 필요>` 값 없음.

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

- `scanOperationEvent`가 nullable `operation_id`를 처리하는지 검증하는 non-DB unit test가 없다.
- empty `OperationID` branch event를 append한 뒤 pending list/mark published까지 읽는 Postgres integration assertion이 없다.
- broadcaster failure 이후 revision cursor와 provider delivery dedupe state가 advance되지 않는 regression test가 없다.
- retry가 성공 broadcaster로 다시 publish/store할 수 있는 assertion이 없다.

### 심볼 참조

- rename/remove 없음.
- 관련 참조: `rg -n "scanOperationEvent|AppendEvent|ListPendingEvents|MarkPublished|ScanAgentRunRevision|ScanBranchRevision|HandleRevision\\(|UpsertRevisionCursor|RecordOnce" services/core/internal -g '*.go'`.

### 분할 판단

- split decision policy를 적용했다.
- 공유 task group: `m-iop-agent-run-bridge`.
- 현재 subtask: `04+03_revision_scan_event`.
- predecessor `03_commit_push_policy`는 이미 archive에 `complete.log`가 있어 충족됐다.
- 두 Required는 같은 branch.updated durable publish boundary를 구성한다. NULL read만 고쳐도 publish failure retry가 깨지고, retry order만 고쳐도 outbox row를 읽을 수 없으므로 같은 plan에서 함께 고친다.

### 범위 결정 근거

- 포함: generic branch.updated outbox row의 nullable operation id read/write round trip.
- 포함: scan publish/store failure 시 cursor와 dedupe state를 advance하지 않는 ordering/transaction boundary.
- 포함: regression tests that fail without the fix and pass without external DB secrets.
- 제외: full background outbox publisher loop, external Forgejo smoke, IOP CLI, credential injection, Flutter UI.
- 제외: unrelated operation lifecycle schema redesign.

### 빌드 등급

- build=`cloud-G07`, review=`cloud-G07`. 반복 FAIL이고 storage NULL, durable outbox, dedupe/cursor ordering, proto-socket event contract가 같은 행동 경계에 걸려 있어 cloud-grade cross-boundary 검토가 필요하다.

## 구현 체크리스트

- [ ] `operation_events.operation_id=NULL` row가 `scanOperationEvent`, `ListPendingEvents`, `MarkPublished`에서 empty operation id로 안전하게 round trip되도록 수정한다.
- [ ] package-internal storage regression test와 가능한 Postgres integration assertion으로 empty-operation branch event read/mark behavior를 검증한다.
- [ ] `HandleRevision`/`ScanBranchRevision` durable state ordering을 바꿔 publish/store 실패 시 provider delivery와 revision cursor가 advance되지 않도록 한다.
- [ ] failing broadcaster 후 재시도 성공 경로를 controlplane test로 검증한다.
- [ ] `cd services/core && go test -count=1 ./internal/controlplane ./internal/storage`를 실행한다.
- [ ] `cd services/core && go test -count=1 ./internal/worker ./internal/controlplane ./internal/storage`를 실행한다.
- [ ] `cd services/core && go test ./...`를 실행한다.
- [ ] 계약 문구를 수정했다면 `rg --sort path -n "proto-socket|REST|gRPC|ChangeRequest|Operation|Event|agent_shell|agent-shell|iop" packages/contracts README.md docs`를 실행한다.
- [ ] `git diff --check`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [REVIEW_REVIEW_REVIEW_API-1] Nullable operation id outbox read path

#### 문제

[services/core/internal/storage/postgres.go](/config/workspace/gito/services/core/internal/storage/postgres.go:916)는 empty `OperationID`를 SQL NULL로 저장한다. 하지만 [services/core/internal/storage/postgres.go](/config/workspace/gito/services/core/internal/storage/postgres.go:1027)의 `scanOperationEvent`는 `operation_id`를 `string`으로 직접 scan한다. pgx는 NULL을 `*string`에 scan할 수 없으므로 worker가 append한 generic `branch.updated` outbox row는 pending list나 mark-published read path에서 깨진다.

#### 해결 방법

`scanOperationEvent`에서 `operation_id`만 `sql.NullString`으로 받고, valid일 때만 `ev.OperationID`에 할당한다.

Before:

```go
// services/core/internal/storage/postgres.go:1023
func scanOperationEvent(scanner repoScanner) (OperationEvent, error) {
	var ev OperationEvent
	var publishedAt sql.NullTime
	if err := scanner.Scan(
		&ev.OperationID,
```

After shape:

```go
func scanOperationEvent(scanner repoScanner) (OperationEvent, error) {
	var ev OperationEvent
	var operationID sql.NullString
	var publishedAt sql.NullTime
	if err := scanner.Scan(
		&operationID,
		&ev.Event.ID,
		// ...
	); err != nil {
		return OperationEvent{}, err
	}
	if operationID.Valid {
		ev.OperationID = operationID.String
	}
	// ...
}
```

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

- [ ] `services/core/internal/storage/postgres.go`: nullable operation id scan을 지원한다.
- [ ] `services/core/internal/storage/postgres_internal_test.go`: package `storage` test로 `scanOperationEvent`가 NULL operation id를 empty string으로 복원하는지 검증한다.
- [ ] `services/core/internal/storage/postgres_test.go`: `GITO_TEST_DATABASE_URL`가 있을 때 empty-operation event append 후 `ListPendingEvents`와 `MarkPublished`까지 통과하도록 integration assertion을 보강한다.

#### 테스트 작성

작성한다.

- `services/core/internal/storage/postgres_internal_test.go`: `TestScanOperationEventAllowsNullOperationID`를 추가한다. fake scanner가 `sql.NullString{Valid:false}` 또는 nil-equivalent source를 제공하고, 결과 `OperationID == ""`, payload, type, subject, published_at mapping을 검증한다.
- `services/core/internal/storage/postgres_test.go`: 기존 `TestPostgresOperationEventStoreAppendsListsMarksPublishedAndValidates`에서 empty-operation event를 append한 뒤 pending list와 mark published 결과까지 확인한다. DB env가 없으면 기존처럼 skip되지만, package-internal unit test는 항상 실행되어야 한다.

#### 중간 검증

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

기대: `GITO_TEST_DATABASE_URL` 유무와 무관하게 package-internal NULL scan regression test가 실행되고 0 exit 통과한다.

### [REVIEW_REVIEW_REVIEW_API-2] Publish failure must not advance scan durable state

#### 문제

[services/core/internal/controlplane/runtime.go](/config/workspace/gito/services/core/internal/controlplane/runtime.go:210)는 provider delivery dedupe record를 먼저 저장하고, [services/core/internal/controlplane/runtime.go](/config/workspace/gito/services/core/internal/controlplane/runtime.go:223)는 revision cursor를 먼저 `after`로 이동한 뒤, [services/core/internal/controlplane/runtime.go](/config/workspace/gito/services/core/internal/controlplane/runtime.go:240)에서 broadcaster publish/store를 호출한다. outbox append가 실패하면 worker operation은 failed가 되지만 cursor/dedupe state는 이미 advance되어 재시도가 같은 revision event를 다시 publish/store할 수 없다.

#### 해결 방법

matched revision에 대해서는 publish/store가 성공한 뒤 durable cursor와 dedupe state를 확정한다. 구현 선택지는 둘 중 하나다.

- publish/store append, provider delivery, cursor update를 하나의 transaction-capable boundary로 묶는다.
- 현재 store abstraction을 유지한다면 scan-origin event에서 broadcaster append를 먼저 성공시킨 뒤 provider delivery/cursor를 기록하고, 실패 시 cursor/dedupe가 변경되지 않도록 order를 바꾼다.

Before:

```go
// services/core/internal/controlplane/runtime.go:210
result, err := r.store.ProviderDeliveries().RecordOnce(ctx, delivery)
// ...
if err := r.store.RevisionCursors().UpsertRevisionCursor(ctx, cursor); err != nil {
	return record, true, fmt.Errorf("upsert revision cursor: %w", err)
}
// ...
if err := r.broadcaster.BroadcastEnvelope(ctx, branchUpdatedEnvelope(record)); err != nil {
	return record, true, err
}
```

After shape:

```go
env := branchUpdatedEnvelope(record)
if r.broadcaster != nil {
	if err := r.broadcaster.BroadcastEnvelope(ctx, env); err != nil {
		return record, true, err
	}
}
// Only after publish/store succeeds: record delivery and advance cursor.
```

If duplicate delivery handling requires existing idempotent response semantics for provider webhooks, preserve duplicate no-broadcast behavior for already-published deliveries, but do not create a duplicate marker before the first publish/store has succeeded.

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

- [ ] `services/core/internal/controlplane/runtime.go`: publish/store failure does not mutate cursor/dedupe state.
- [ ] `services/core/internal/controlplane/runtime_test.go`: failing broadcaster leaves cursor at previous revision and does not leave a dedupe record that suppresses retry.
- [ ] `services/core/internal/controlplane/runtime_test.go`: retry with a succeeding broadcaster emits one `branch.updated` envelope and advances cursor.
- [ ] `services/core/internal/worker/runner_test.go`: existing sanitized failure payload tests still pass.
- [ ] `services/core/cmd/worker/main.go`: worker scanner still injects a non-nil publish/store boundary.

#### 테스트 작성

작성한다.

- `TestRuntimeScanAgentRunRevisionPublishFailureDoesNotAdvanceDurableState`: arrange watched branch, initial cursor, second commit, failing broadcaster. Assert `ScanAgentRunRevision` returns error, cursor remains at `before`, delivery records do not suppress retry, then retry with success broadcaster produces matched result and one envelope.
- Existing `TestRuntimeScanAgentRunRevision` should still assert nil broadcaster/fake broadcaster behavior.

#### 중간 검증

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

기대: publish failure ordering regression이 실패 없이 통과한다.

## 수정 파일 요약

| 파일 | 항목 |
| --- | --- |
| `services/core/internal/storage/postgres.go` | REVIEW_REVIEW_REVIEW_API-1 |
| `services/core/internal/storage/postgres_internal_test.go` | REVIEW_REVIEW_REVIEW_API-1 |
| `services/core/internal/storage/postgres_test.go` | REVIEW_REVIEW_REVIEW_API-1 |
| `services/core/internal/controlplane/runtime.go` | REVIEW_REVIEW_REVIEW_API-2 |
| `services/core/internal/controlplane/runtime_test.go` | REVIEW_REVIEW_REVIEW_API-2 |
| `services/core/internal/worker/runner_test.go` | REVIEW_REVIEW_REVIEW_API-2 |
| `services/core/cmd/worker/main.go` | REVIEW_REVIEW_REVIEW_API-2 |

## 최종 검증

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

```bash
cd services/core && go test -count=1 ./internal/worker ./internal/controlplane ./internal/storage
```

```bash
cd services/core && go test ./...
```

계약 문구를 수정했다면 추가 실행:

```bash
rg --sort path -n "proto-socket|REST|gRPC|ChangeRequest|Operation|Event|agent_shell|agent-shell|iop" packages/contracts README.md docs
```

```bash
git diff --check
```

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