# D6 Migration Runbook — Ordered Force Re-run

> 운영자용 — D6 deploy 또는 `background_master_plan` 갱신 후 downstream 재실행 절차.

## When to use

다음 중 하나에 해당하면 본 runbook 따라 실행:

1. **D6 mode episode 의 consumer stale** — `background_master_plan` 에 `bg_catalog_hash` 가 stamped 된 episode 에서 4 consumer 중 하나의 `consumed_*_hash` 가 mismatch → 첫 image 생성 호출 시 HTTP 422 `STALE_UPSTREAM` 응답.
2. **`background_master_plan` 강제 재실행** 후 (예: 새 prompt 적용) downstream 4 consumer 가 자동 cascade 안 됨 → ordered force 로 갱신.
3. **Pre-D6 episode 마이그레이션** — bg_id 가 longform (`bg_*`) 인 episode 를 새 D6 형식 (`L##B##`) 으로 변환. **주의**: pre-D6 episode 는 preflight 차단 안 됨 (`background_master_plan` 에 `bg_catalog_hash` 부재 → D5 fallback silent return, `dispatcher_preflight.py:111`). 대신 image 생성이 시도되면 `ref_contract_validator` 가 legacy `bg_*` 미부착 시 `RefContractError` raise. 어느 경우든 본 runbook 의 ordered force migration 으로 D6 형식 전환.

**근본 원리**: D6 의 cascade 는 `_config_hash` mismatch 가 아닌 **operator 명시 force** 로 작동. `background_master_plan` 이 D6 hash 를 stamp 한 episode 에 한해 dispatcher preflight 가 4 consumer 의 `consumed_*_hash` 와 비교, 불일치 시 422 차단 (T8 §4.8). master_plan 자체에 D6 hash 가 없는 (pre-D6) episode 는 preflight 가 silent skip — D5 fallback path.

---

## Force order (6 step, 순서 엄격)

다음 순서 그대로 force re-run. 순서 어긋나면 preflight 가 다음 generate-image 호출 시점에 차단.

> **중요 — step 완료 대기 의무**: 각 step 의 force endpoint (`POST /steps/{step_id}?mode=force`) 는 background job **시작만** 반환 (`backend/app/api/v1/steps.py:185`, `backend/app/services/step_execution_service.py:202`). 6 curl 을 연속 실행하면 다음 step 이 이전 cp 완료 전에 시작될 수 있다. 각 force 후 아래 `_wait_step_completed` polling 으로 해당 step `status="completed"` 를 확인한 뒤 다음 step force 실행.

```bash
PID="<project_id>"
EID="<episode_id>"
COOKIE="/tmp/theroad_cookies.txt"
BASE="http://localhost:8000"

# helper: step status 가 completed 될 때까지 대기 (max 600s, 5s interval)
_wait_step_completed() {
  local step="$1"
  local elapsed=0
  while [ $elapsed -lt 600 ]; do
    local status=$(curl -sS -b $COOKIE \
      "$BASE/api/v1/projects/$PID/episodes/$EID/steps" \
      | python3 -c "import sys,json; d=json.load(sys.stdin); \
                    print(next((s['status'] for s in d['steps'] \
                                if s['step_id']=='$step'), 'missing'))")
    if [ "$status" = "completed" ]; then
      echo "[ok] $step completed (${elapsed}s)"
      return 0
    fi
    if [ "$status" = "failed" ]; then
      echo "[FAIL] $step failed — inspect manifest 후 force 재시도"
      return 1
    fi
    sleep 5
    elapsed=$((elapsed + 5))
  done
  echo "[TIMEOUT] $step not completed after ${elapsed}s"
  return 1
}
```

### 1. `background_master_plan`

새 prompt + post-processing 6 단계 적용 (raw intent → semantic_key dedup → assign deterministic L##B## → handshake → catalog/shot_bg_map 빌드 → 양쪽 hash 계산).

```bash
curl -sS -b $COOKIE -X POST \
  "$BASE/api/v1/projects/$PID/episodes/$EID/steps/background_master_plan?mode=force"
_wait_step_completed background_master_plan || exit 1
```

### 2. `floor_plan_prompt`

새 catalog 의 floor plan 의존성으로 prompt 갱신 (`applied_shots` 가 user prompt 의 `{shots_block}` 으로 inject 되므로 binding 변경 시 prompt 입력 변동 — 양쪽 hash 모두 stamp).

```bash
curl -sS -b $COOKIE -X POST \
  "$BASE/api/v1/projects/$PID/episodes/$EID/steps/floor_plan_prompt?mode=force"
_wait_step_completed floor_plan_prompt || exit 1
```

### 3. `floor_plan_render` (R2 I4 — 운영자 책임)

> **주의**: `floor_plan_render` 는 D6 hash stamp 대상이 **아님** (spec §3 Non-goals A3). 따라서 dispatcher preflight 가 fp_render stale 을 자동으로 catch 하지 **않음**. 그러나 `floor_plan_prompt` 가 갱신되면 fp PNG 의 시각 정합성 (LLM 의 새 catalog 와 fp 가 어긋나면 결함) 보장을 위해 **운영자가 명시 force 의무**.

```bash
curl -sS -b $COOKIE -X POST \
  "$BASE/api/v1/projects/$PID/episodes/$EID/steps/floor_plan_render?mode=force"
_wait_step_completed floor_plan_render || exit 1
```

비용 발생 (gpt-image-2 호출). 도면 변경 없음이 확실하면 skip 가능 — 단 fp PNG 와 새 catalog 의 시각 mismatch 위험 운영자 판단.

### 4. `background_prompt`

새 catalog + 갱신된 fp PNG 기준으로 t2i prompt + shot_guides 갱신.

```bash
curl -sS -b $COOKIE -X POST \
  "$BASE/api/v1/projects/$PID/episodes/$EID/steps/background_prompt?mode=force"
_wait_step_completed background_prompt || exit 1
```

### 5. `background_render`

새 t2i prompt 로 bg PNG 재생성 (gpt-image-2 비용 발생).

```bash
curl -sS -b $COOKIE -X POST \
  "$BASE/api/v1/projects/$PID/episodes/$EID/steps/background_render?mode=force"
_wait_step_completed background_render || exit 1
```

### 6. `scene_detail`

Per-shot RPC 의 `background_binding.bg_id` 가 새 `L##B##` 형식으로 stamp (LLM/prompt 변경 0 — 코드 chain 만 자동 정합).

```bash
curl -sS -b $COOKIE -X POST \
  "$BASE/api/v1/projects/$PID/episodes/$EID/steps/scene_detail?mode=force"
_wait_step_completed scene_detail || exit 1
```

---

## Wrong-order detection (dispatcher preflight)

순서 어긋나면 다음 `generate-image` 호출 시 dispatcher preflight (`app/services/dispatcher_preflight.py`) 가 endpoint 진입 직후 (다른 모든 gate 보다 먼저) 422 차단:

```http
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{
  "error": {
    "code": "STALE_UPSTREAM",
    "message": "STALE_UPSTREAM upstream=background_render",
    "upstream": "background_render",
    "expected_bg_catalog_hash": "abc123def456",
    "observed_bg_catalog_hash": "STALE_HASH_xyz",
    "remediation": "POST /steps/background_render?mode=force (catalog drift — see docs/runbooks/d6-migration.md)"
  }
}
```

**응답 shape 주의** (Codex review iter1):
- `force` 는 별도 top-level 필드가 **아님** — `remediation` 문자열 안의 가이던스 substring.
- `expected_shot_binding_hash` / `observed_shot_binding_hash` 는 binding mismatch 케이스에서만 추가 (catalog mismatch 시 미포함).
- `missing_in_render` 는 validator fallback path (T9) 에서만 추가.

`upstream` 필드가 어느 consumer 가 stale 인지 정확히 명시 — 해당 step 만 force 하면 충분 (전체 6 step 재실행 불필요한 경우 있음).

---

## Custom prompt path (단건 endpoint)

`POST /api/v1/projects/$PID/stills/$STILL_ID/generate-image` 본문에 `custom_prompt` 가 있고 비어있지 않으면:

- `enforce_shot_binding=False` — binding-only stale 은 통과 (operator 가 one-off prompt 작성 의도, D6 binding 자동 재바인딩 대상이지만 catalog freshness 는 강제).
- catalog stale 은 여전히 차단 (catalog drift 는 제대로 된 generation 자체가 불가).
- `{"custom_prompt": ""}` 또는 whitespace-only 는 None 으로 정규화 (T8b-fix iter1) → automatic path = `enforce_shot_binding=True`.

배치 endpoint (`POST /api/v1/projects/$PID/episodes/$EID/generate-images`) 는 `enforce_shot_binding=True` 강제 (custom_prompt 의미 없음).

---

## Cost estimate (per episode)

| Item | Approx |
|---|---|
| gpt-image-2 (bg PNG) | 24 bg × ~$0.15 ≈ $3.6 |
| gpt-image-2 (fp PNG) | 14 fp × ~$0.10 ≈ $1.4 |
| Gemini text (prompts) | ~$0.5 |
| **Total** | **~$5.5** |

`floor_plan_render` skip 시 ~$1.4 절감 가능 (단 시각 정합성 운영자 판단 필요).

---

## Verification

각 step force 완료 후 manifest status + mtime 확인:

```bash
curl -sS -b $COOKIE \
  "$BASE/api/v1/projects/$PID/episodes/$EID/steps" \
  | python3 -c "import sys, json; d = json.load(sys.stdin); \
[print(s['step_id'], s['status'], s.get('completed_at', '')) \
 for s in d['steps'] \
 if s['step_id'] in ('background_master_plan', 'floor_plan_prompt', \
                     'floor_plan_render', 'background_prompt', \
                     'background_render', 'scene_detail')]"
```

기대: 6 step 모두 `completed`. 그 후 단건 `generate-image` 재호출 → HTTP 200 정상 응답.

---

## Validator fallback safety net (T9)

Endpoint preflight 가 우회된 경로 (예: service 직접 호출, dispatcher 외 path) 에서 `ref_contract_validator.validate_attached_refs` 가 backup detection. D6 형식 (`L##B##`) bg_id 가 attached_meta 에 없으면 동일 `STALE_UPSTREAM` raise (단건 endpoint 기준 422). legacy `bg_*` 형식은 기존 `RefContractError` (D5 정합 보존).

Batch variation thread (T9-fix) 도 `_await_variation_results` 헬퍼가 `StaleUpstreamError` 별도 catch + re-raise → 전체 batch abort (단순 "all variations failed" 로 무력화 차단).

**경로별 운영자 가시성 차이**:
- **단건 HTTP endpoint** (`POST /stills/{still_id}/generate-image`): `app_error_handler` 가 `StaleUpstreamError.details` 를 422 응답에 structured serialize — `code/upstream/expected_*/observed_*/remediation` 모두 noted.
- **배치 background job** (`POST /episodes/{episode_id}/generate-images`): endpoint 진입 시 sync preflight 통과 후 백그라운드 thread 실행. 백그라운드에서 `StaleUpstreamError` 발생 시 progress wrapper 가 `PipelineProgress.error_message = str(exc)[:2000]` 로 **문자열만** 저장 (`backend/app/api/v1/images.py:520`). 운영자는 progress polling 으로 메시지 확인 후 logs 에서 structured detail 조회 의무.

---

## Scene Image Incident Closure Notes

> incident-bound 운영 작업 (D2 phantom guard / 24-shot ref-contract / scene_image_pipeline 회복 등) 마무리 시 자주 밟히는 함정 + 정상 closure path. 일회성 PID-hardcoded canary 스크립트는 본 노트로 흡수 후 삭제.

### 함정 (피할 것)

1. **Prompt DB sync 불필요** — prompt 변경분은 file-only path 로 로드 (`translate_if_korean` 등). prompt 갱신만으로는 DB 의 episode-level state 가 바뀌지 않으므로 별도 sync 호출 의미 없음.
2. **`_analyze_one()` manual script 는 DB sync 안 함** — `SceneDetailStep._analyze_one()` 직접 호출은 checkpoint `manifest.json` 의 해당 scene entry 만 갱신. `SceneStill.t2i_variations_json` 등 DB projection 은 동기화되지 않음 → manifest/DB drift 발생. 보정하려면 직후 `orchestrate_full_sync(step_id="scene_detail")` 명시 호출.
3. **`SceneImageService.generate_images()` direct call 은 StepRunner 우회** — image checkpoint 와 `ImageAsset` row 는 회복되지만 `scene_image_pipeline` step manifest + `step_run` row (status / completed_count / failed_count) 는 stale 한 채 남음. 운영자/UI 는 step 단위 progress 만 보므로 67/67 이미지가 있어도 manifest 가 partial 65/2 로 표시될 수 있음.
4. **`force` 는 재생성 비용 발생** — 67 shot pipeline 에 force 적용 시 모든 primary image 가 재생성됨 (Gemini + gpt-image-2 비용). 이미 1차 closure 끝난 episode 의 read-model 정렬 목적이라면 `mode="resume"` 으로 충분 (이미 primary 가 있는 still 은 skip + count 만 카운트됨).

### 정상 closure path

- **scene_detail manual recovery** (checkpoint manifest 직접 갱신 후 정합):
  ```python
  # 1. checkpoint manifest 의 해당 scene entry 만 _analyze_one() 으로 재산출 + manifest write
  # 2. orchestrate_full_sync(PID, EID, db, step_id="scene_detail") 호출 — DB projection 동기
  # 3. SceneStill.t2i_variations_json 검증 (보존돼야 할 ID 가 t2i_prompt 에 들어갔는지)
  ```
- **scene_image_pipeline read-model 정렬** (이미지는 회복됐는데 manifest 만 stale 일 때):
  ```python
  runner = get_step_runner("scene_image_pipeline", PID, EID, db, config, opik_context={})
  runner.run(mode="resume")  # step manifest + step_run count/status 정렬
  orchestrate_full_sync(PID, EID, db, step_id="scene_image_pipeline")  # sync_status / DB projection 정렬
  ```

핵심 원칙: **incident 회복 후 read-model 정렬은 항상 `StepRunner.run(mode="resume")` + `orchestrate_full_sync(step_id=...)` 를 짝으로 호출.** service-layer direct call 은 빠르지만 step manifest 가 stale 해진다.

---

## References

- spec: `docs/superpowers/specs/2026-05-09-deterministic-bg-id-and-catalog-lineage.md`
- plan: `docs/superpowers/plans/2026-05-09-d6-deterministic-bg-id-and-catalog-lineage-implementation.md`
- preflight 구현: `backend/app/services/dispatcher_preflight.py`
- error class: `backend/app/core/errors.py` (`StaleUpstreamError`)
- 단건 endpoint wiring: `backend/app/api/v1/images.py:387` (`generate_still_image`)
- 배치 endpoint wiring: `backend/app/api/v1/images.py:598` (`generate_images`)
