# Resume 무결성 재설계 — Verify + Cleanup Framework

**작성일**: 2026-05-01
**상태**: Design (사용자 검토 대기)
**연관 사고**: PID 0bb48ebf (2026-05-01) — pytest fixture가 production DB wipe 후 manifest archive 복원 → status=completed인데 image_asset 0 row → silent fallback으로 캐릭터 random 생성
**Origin memory**: `next_session_integrity_validation.md`
**HEAD baseline**: `98e2645` (resilience 4 fix + 듀얼 리뷰 8 보강)

## 1. 문제 정의

각 파이프라인 단계가 `step_run.status='completed'`로 표시되어 있어도, 그 단계가 만들어야 할 **실제 산출물(DB row + 디스크 파일)이 존재하는지는 어느 진입 경로에서도 검증되지 않는다.**

이 사고는 다음 8 결함이 동시에 작용하여 발생했다 (전수 분석):

| # | 결함 | 위치 | 심각도 | 본 spec |
|---|---|---|---|---|
| **D1** | resume 모드 DB projection 검증 부재 | `step_runner.py` resume 분기 | CRITICAL | ✅ 포함 |
| **D2** | `save_checkpoint`이 `project_config_snapshot` 미기록 → `_diff_project_config` 매번 None reference 폴백 → 매번 stale auto-rerun trigger | `step_runner.py:save_checkpoint` | HIGH | ✅ 포함 |
| **D3** | `resolve_refs_for_prompt` composite 부재 시 face-only로 silent fallback (캐릭터 random 직접 원인) | `scene_reference_service.py` | CRITICAL | ✅ 포함 |
| **D4** | `check_scene_images_ready`가 character entity + composite만 검증, outlook 단독/state_variant/chain_bg/floor_plan 무검증 | `pipeline_gate.py` | HIGH | ✅ 포함 |
| **D5** | `ImageAsset.entity_id`가 FK 아님 (Text) — orphan row 가능 | `models/project.py` | MED | ⏭ 후속 spec |
| **D6** | `variant_label varchar(8)` migration 미적용 (chain_bg label truncation 회귀 위험) | `alembic/versions/002_*.py` | HIGH | ✅ Phase 0 migration에 포함 |
| **D7** | opik trace flush 누락 | `litellm CustomBatchLogger` | MED | ⏭ 후속 spec |
| **D8** | image_validator OpenAI Vision temperature 미지원 (매번 validation fail) | `image_validator.py` | MED | ⏭ 후속 spec |

본 spec scope = **D1 + D2 + D3 + D4 + D6** (D5/D7/D8은 별도 spec).

### 사용자 핵심 요구

1. **앞쪽 단계 끝났는지 파악하는 루틴** — step 진입 시 의존성 산출물 무결성 검증
2. **현 단계 재진행 시 찌꺼기 데이터 정리 (매우 조심)** — force/auto-rerun 시 stale row 정리, 단 부분 재생성을 깨지 않도록
3. **이미지는 거의 대부분 재생성 가능해야 한다** — entity별/shot별/group별 부분 재생성 endpoint와 force가 충돌 금지

## 2. 핵심 설계 결정

| 결정 | 채택안 | 근거 |
|---|---|---|
| **검증 실패 시 동작** | 자동 force-rerun (silent recovery) | Fix 3.1 패턴 일관, 운영 편함. 가시성 보강(추천 2)으로 사고 묻힘 방지. |
| **Cleanup 책임 분담** | StepRunner 통일 hook (default noop, override는 매우 보수적) | 점진 도입 가능. 부분 재생성 보호. |
| **검증 시점** | 양방향 (resume entry + execute exit) | 모든 진입 경로 커버. 호출 site는 `StepRunner.run()` 한 메서드 내 2시점 — 분산 비용 0. |
| **Runtime fallback** | fail-soft (composite 누락 시 shot skip + failed_count++) | 잘못된 PNG 안 만듦 + auto-rerun 정책 일관 |
| **이미지 step cleanup** | 거의 모두 default noop 유지 | 사용자 caveat — 부분 재생성 endpoint 보호 |

### 핵심 사고 방지 메커니즘 (4 layer)

1. **`verify_completion`** (per-step) — 산출물 무결성 contract. entry/exit 양방향 호출.
2. **Auto-rerun** — verify 실패 또는 config_hash mismatch → mode='force' 격상 (Fix 3.1 패턴 확장).
3. **`cleanup_artifacts`** (per-step, default noop) — hook은 마련, 이미지 step은 거의 override 안 함.
4. **Runtime fail-soft** — `resolve_refs_for_prompt` + `scene_image_pipeline`. ref 누락 시 shot skip, 잘못된 PNG 안 만듦.

## 3. Architecture

```
[Resume entry]  mode='resume' + step_run.status='completed'
   ↓
   verify_completion()  ← 산출물 무결성 검증
   ├─ pass → skipped
   └─ fail → status='stale' + recovery_count++ + last_recovery_reason 기록 + mode='force' 격상

[Status별 force-like 진입]  status ∈ {running, failed, partial, stale, pending}
   ↓                       또는 status='completed'이지만 cp=None (manifest 폐기 상태)
   ↓                       (completed-with-valid-cp 외 모든 상태가 force-like — 추천 1)

[Force path]  mode='force'
   ↓
   cleanup_artifacts()  ← 베이스 noop. 이미지 step은 override 안 함
   ↓
   invalidate_downstream() (현행)
   ↓
   clear_checkpoint() (현행, .force_cleared marker)
   ↓
[Execute]
   _execute(mode)  ← 부분 재생성은 _execute() 내 UPSERT가 담당 (현행)
   ↓
[Runtime fail-soft]  (D3 — ref 의존 step만)
   resolve_refs_for_prompt: missing_refs 감지
   → 해당 shot skip + failed_count++ (PNG/scene_still row 안 만듦)
   ↓
[Exit verify]
   verify_completion()  ← 같은 메서드 재호출
   ├─ pass → save_checkpoint(status='completed') with project_config_snapshot (D2)
   └─ fail → save_checkpoint(status='partial') — 다음 resume entry verify가 다시 잡음
```

### 진입 경로 일관성

API resume / run-all / dispatcher cascade auto-include / 직접 step 호출 — 모든 경로가 `StepRunner.run()`을 거치므로 entry verify가 자동 적용된다. dispatcher 레벨에서 별도 verify 안 함 — cross-step dep verify(consumer가 producer 산출물도 검증)는 후속 spec으로 분리.

## 4. Components

### 4.1 새 dataclass 2종

```python
# backend/app/core/integrity_report.py (신규)

@dataclass(frozen=True)
class CompletionReport:
    is_complete: bool
    missing: list[str]           # 사람이 읽는 결손 목록
    severity: Literal["clean", "partial", "missing"]
    metadata: dict               # 진단용 — count/file paths

@dataclass(frozen=True)
class CleanupReport:
    deleted_db_rows: int
    deleted_files: int
    targets: list[str]           # 정리된 대상 (감사 로그)
    skipped: list[str]           # self-origin 외 보호 대상 (drift detection)
```

### 4.2 `StepRunner` 베이스 — 새 메서드

```python
# backend/app/core/step_runner.py 추가

def verify_completion(self) -> CompletionReport:
    """산출물 무결성 검증. 베이스는 항상 complete — override 안 한 step은 검증 없음."""
    return CompletionReport(is_complete=True, missing=[], severity="clean", metadata={})

def cleanup_artifacts(self) -> CleanupReport:
    """force/auto-rerun 시 stale 정리. 베이스는 noop.

    Override 작성 기준 (매우 보수적):
    - 전체 force = delete-and-rebuild가 항상 안전한 step만
    - 부분 재생성을 endpoint로 지원하는 step은 절대 override 금지
    - 이미지 step은 거의 모두 default noop 유지 (사용자 caveat)
    """
    return CleanupReport(deleted_db_rows=0, deleted_files=0, targets=[], skipped=[])

def _mask_sensitive_keys(self, config: dict) -> dict:
    """project_config 저장 전 민감 키 마스킹. api_key/password/token/secret 패턴."""
    SENSITIVE = ("api_key", "password", "secret", "token", "credential")
    masked = {}
    for k, v in (config or {}).items():
        if any(s in k.lower() for s in SENSITIVE):
            masked[k] = "***"
        elif isinstance(v, dict):
            masked[k] = self._mask_sensitive_keys(v)
        else:
            masked[k] = v
    return masked
```

### 4.3 `StepRunner.run()` — 호출 site 통합

```python
def run(self, mode='resume'):
    self.check_gate()
    if not self.check_applicability(): ...

    existing = self._get_step_run(self.step_id)
    cur_status = existing[0] if existing else None
    cur_recovery_count = existing[3] if existing and len(existing) > 3 else 0

    # ── 추천 1: status별 분기 통일 ──
    if mode == 'resume' and cur_status == 'completed':
        cp = self.load_checkpoint()
        mismatch: Optional[str] = None

        if cp is None:
            # status=completed인데 cp 없음 = projection mismatch (manifest 폐기 / DB stale).
            # PID 0bb48ebf 사고 패턴 (4 step의 manifest 미복원 + step_run.completed) 재발 가드.
            mismatch = "checkpoint missing but step_run.status=completed"
        else:
            mismatch = self._check_cp_mismatch(cp)         # 현행 — schema/config_hash + snapshot diff
            if not mismatch:
                report = self._safe_verify_completion()    # NEW — 산출물 검증 (예외 안전)
                if not report.is_complete:
                    mismatch = f"verify failed: {report.missing}"

        if mismatch:
            new_count = self._record_recovery(mismatch)    # NEW — recovery_count++ + reason 적재
            logger.warning("[RECOVERY] step=%s reason=%s cycle=%d",
                           self.step_id, mismatch, new_count)
            mode = 'force'                                 # Fix 3.1 패턴 확장
        else:
            return {"status": "skipped"}
    elif cur_status in ('running', 'failed', 'partial', 'stale', 'pending'):
        # 추천 1: completed-with-valid-cp 외 모든 상태는 force-like 처리.
        # pending = step_run row는 있는데 한 번도 실행 안 됨 (드물지만 dispatcher race로 가능).
        logger.warning("[RECOVERY] step=%s status=%s → force-like", self.step_id, cur_status)
        mode = 'force'

    # ── Recovery 한계 차단 (추천 2) ──
    # mismatch로 mode=force로 격상된 경우 + cur_recovery_count가 이미 한계인 경우 차단.
    # _record_recovery가 카운터를 증가시킨 후 검사 (현재 cycle도 카운트에 포함).
    if mode == 'force' and self._get_recovery_count() >= MAX_RECOVERY_ATTEMPTS:  # default 3
        raise AppError(
            code="step.recovery_exhausted",
            message=f"{self.step_id} auto-recovery {self._get_recovery_count()}회 실패. "
                    f"마지막 사유: {self._get_last_recovery_reason()}. 수동 진단 필요.",
            status_code=409,
        )

    # ── Force path ──
    if mode == 'force':
        try:
            cleanup_report = self.cleanup_artifacts()
        except Exception as exc:
            # cleanup 실패는 즉시 raise — DB inconsistent 위험
            self._update_step_run('failed', error_message=f"cleanup_artifacts crashed: {exc}")
            raise
        if cleanup_report.deleted_db_rows or cleanup_report.deleted_files:
            logger.warning("[CLEANUP] step=%s rows=%d files=%d targets=%s",
                           self.step_id, cleanup_report.deleted_db_rows,
                           cleanup_report.deleted_files, cleanup_report.targets)
        self.invalidate_downstream()
        self.clear_checkpoint()

    # ── Execute ──
    self._update_step_run("running")
    result = self._execute(mode)
    completed = result.get("completed_count", 1)
    total = result.get("applicable_count", 1)
    failed = result.get("failed_count", 0)
    final_status = 'completed' if failed == 0 else ('partial' if completed > 0 else 'failed')

    # ── Exit verify ──
    if final_status == 'completed':
        report = self._safe_verify_completion()
        if not report.is_complete:
            logger.warning("[VERIFY-EXIT] step=%s failed → partial: %s",
                           self.step_id, report.missing)
            final_status = 'partial'

    # ── Save with snapshot (D2 fix) ──
    cp_data = {
        "status": final_status, **result,
        "project_config_snapshot": self._mask_sensitive_keys(self.project_config),  # NEW
    }
    self._update_step_run(final_status, ...)
    if final_status == 'completed':
        self._reset_recovery_counter()                     # NEW
    self.save_checkpoint(cp_data)

def _safe_verify_completion(self) -> CompletionReport:
    """verify_completion이 예외를 던지면 안전하게 is_complete=False로 처리."""
    try:
        return self.verify_completion()
    except Exception as exc:
        logger.error("verify_completion crashed for %s: %s", self.step_id, exc)
        return CompletionReport(
            is_complete=False, missing=[f"verify_crashed: {exc}"],
            severity="missing", metadata={"crash": str(exc)},
        )

def _record_recovery(self, reason: str) -> int:
    """recovery_count++ + last_recovery_reason 적재. 새 카운터값 반환."""
    now = self._now()
    self.db.execute(text("""
        UPDATE step_run SET
            recovery_count = recovery_count + 1,
            last_recovery_reason = :reason,
            updated_at = :now
        WHERE project_id = :pid AND episode_id = :eid AND step_id = :sid
    """), {"reason": reason[:2000], "pid": self.project_id,
           "eid": self.episode_id, "sid": self.step_id, "now": now})
    self.db.commit()
    return self._get_recovery_count()

def _reset_recovery_counter(self) -> None:
    """status=completed 정상 진행 시 카운터 reset (next sweep에서 깨끗한 상태)."""
    now = self._now()
    self.db.execute(text("""
        UPDATE step_run SET recovery_count = 0, last_recovery_reason = NULL, updated_at = :now
        WHERE project_id = :pid AND episode_id = :eid AND step_id = :sid
    """), {"pid": self.project_id, "eid": self.episode_id,
           "sid": self.step_id, "now": now})
    self.db.commit()

def _get_recovery_count(self) -> int:
    row = self.db.execute(text(
        "SELECT recovery_count FROM step_run "
        "WHERE project_id = :pid AND episode_id = :eid AND step_id = :sid"
    ), {"pid": self.project_id, "eid": self.episode_id, "sid": self.step_id}).fetchone()
    return row[0] if row else 0

def _get_last_recovery_reason(self) -> str:
    row = self.db.execute(text(
        "SELECT last_recovery_reason FROM step_run "
        "WHERE project_id = :pid AND episode_id = :eid AND step_id = :sid"
    ), {"pid": self.project_id, "eid": self.episode_id, "sid": self.step_id}).fetchone()
    return (row[0] if row and row[0] else "(없음)")
```

`_get_step_run` 시그니처 확장: 현재 `(status, completed_count, applicable_count)` → `(status, completed_count, applicable_count, recovery_count, last_recovery_reason)`. 사용 site 호환성은 인덱스 기반 접근에서 dataclass-like row로 점진 전환 (또는 `getattr` 사용).

### 4.4 Per-step `verify_completion` 본체 — Phase A 6 step

각 step의 verify는 **자기 산출물만** 검증 (cross-step dep verify는 후속 spec). 식별 패턴은 현재 코드(`scene_reference_service.build_scene_ref_image_map`)의 prompt_used 정규식과 일치시킨다.

#### `RefImageGenStep`
```python
def verify_completion(self) -> CompletionReport:
    active_char_ids = self._active_character_entity_ids()  # entity_episode_link + entity_type='character'
    skipped_ids = load_low_freq_skip_ids(self.project_id, self.episode_id)
    expected_ids = [cid for cid in active_char_ids if cid not in skipped_ids]
    expected = len(expected_ids)

    rows = self.db.query(ImageAsset).filter(
        ImageAsset.project_id == self.project_id,
        ImageAsset.episode_id == self.episode_id,
        ImageAsset.asset_type == "reference",
        ImageAsset.is_primary == 1,
        ImageAsset.entity_id.in_(expected_ids),
    ).all() if expected_ids else []
    found = sum(1 for r in rows if Path(r.file_path).exists())

    if found < expected:
        return CompletionReport(
            is_complete=False,
            missing=[f"{expected - found} character ref images missing (expected={expected}, found={found})"],
            severity="missing" if found == 0 else "partial",
            metadata={"expected": expected, "found": found, "rows": len(rows)},
        )
    return CompletionReport(is_complete=True, missing=[], severity="clean",
                            metadata={"found": found})
# cleanup_artifacts override 안 함 → default noop
```

#### `CompositeImageGenStep`
- 기준: O00 제외 + low_freq skip 제외한 모든 `(character_id, outlook_id)` 쌍에 대해 `prompt_used` 가 `composite:{char_id}:{outlook_id}` 정규식과 매칭하는 ImageAsset row 존재 + 파일 존재.
- 식별 패턴: 현재 코드 `_re_comp.search(r'composite:([a-f0-9-]+):([a-f0-9-]+)', ca.prompt_used)`와 동일.

#### `CharacterStateVariantStep`
- 기준: `shot_staging` 의 `dead/severely_injured/unconscious` 상태 인물 (character_id, state) 쌍 수 == ImageAsset (`prompt_used` ~ `state_variant:{char_id}:{state}` 패턴) row.
- 식별 패턴: `state_variant:([a-f0-9-]+):(\w+)`.

#### `OutlookStandaloneStep`
- 기준: O00 제외 + low_freq skip 제외한 outlook entity 수 == ImageAsset(asset_type='reference', entity_type='outlook') row + 파일 존재.

#### `ChainBgRenderStep`
- 기준: `background_chain_planner` 의 `data.groups` 키 수 == ImageAsset(`prompt_used` ~ `chain_bg:{group_id}` 패턴) row.
- planner 산출물 위치: 해당 step의 manifest.json `data.groups`.

#### `FloorPlanRenderStep`
- 기준: planner의 `needs_floor_plan=True` group 수 == ImageAsset(`prompt_used` ~ `floor_plan:{group_id}` 패턴) row.

#### `SceneImagePipelineStep` (Phase 3)
- 기준: scene_still rows == applicable shot 수 + 각 shot의 `image_data_url` 또는 `file_path`로 가리키는 PNG 파일 존재.

### 4.5 D3 Runtime fail-soft — `scene_reference_service.resolve_refs_for_prompt`

시그니처 변경:
```python
def resolve_refs_for_prompt(
    self, t2i_prompt, ...
) -> tuple[list[tuple[str, str]], list[str]]:
    """현재 시그니처 + missing_refs 반환.

    composite/face/state_variant ref가 prompt에 등장하는 entity 대비 누락이면
    missing_refs에 사유 적재. caller(scene_image_pipeline)가 이를 확인해 shot skip.
    """
    labeled_refs = []
    missing_refs = []
    # 기존 1) C##O## 패턴 처리 — composite 누락 감지 시 missing_refs append
    # 기존 2) 레거시 [[name]+[outlook]] 처리 — 동일
    # 기존 3) prop 처리 — 동일
    return labeled_refs, missing_refs
```

`scene_image_pipeline._execute_one_shot`:
```python
labeled_refs, missing_refs = ref_service.resolve_refs_for_prompt(...)
if missing_refs:
    logger.warning("[RUNTIME-FAILSOFT] shot=%s skipped — missing refs: %s",
                   shot_id, missing_refs)
    return {"status": "skipped", "missing_refs": missing_refs}
# 정상 진행
```

step 종료 시 skip 합산 → `failed_count` → final_status='partial' → exit verify가 partial 확정.

### 4.6 D4 Gate 강화 — `pipeline_gate.check_scene_images_ready`

기존 character ref + composite 검증에 추가:

```python
# 4) outlook standalone 검증 (O00 제외, low_freq skip 제외)
# 5) character_state_variant 검증 (staging의 dead/severely_injured 인물 수 vs ImageAsset)
# 6) chain_bg 검증 (planner group 수 vs ImageAsset)
# 7) floor_plan 검증 (needs_floor_plan group 수 vs ImageAsset)
```

각 검증 실패 시 새 AppError code:
- `gate.incomplete_outlook_standalone`
- `gate.incomplete_state_variant`
- `gate.incomplete_chain_bg`
- `gate.incomplete_floor_plan`

verify_completion이 entry에서 동작하면 이 게이트는 dispatcher 진입 1차 가드로서 보조 — verify가 사고 본질을 잡고, gate는 사용자 직접 진입 시 명확한 에러 메시지 제공.

### 4.7 Schema migration — `alembic 003_resume_integrity.py`

```python
def upgrade():
    # 추천 2: auto-rerun 가시성
    op.add_column("step_run", sa.Column("recovery_count", sa.Integer(), nullable=False,
                                         server_default="0"))
    op.add_column("step_run", sa.Column("last_recovery_reason", sa.Text(), nullable=True))

    # D6: variant_label varchar(8) → varchar(32)
    op.alter_column("image_asset", "variant_label", type_=sa.String(32),
                    existing_nullable=False, existing_server_default="v00")

def downgrade():
    op.drop_column("step_run", "last_recovery_reason")
    op.drop_column("step_run", "recovery_count")
    op.alter_column("image_asset", "variant_label", type_=sa.String(8),
                    existing_nullable=False, existing_server_default="v00")
```

`models/project.py` 동기화:
```python
class StepRun(Base):
    ...
    recovery_count = Column(Integer, nullable=False, default=0, server_default="0")
    last_recovery_reason = Column(Text, nullable=True)
```

## 5. Data flow — 사고 시나리오별 회복 경로

### 시나리오 A — backend kill 도중 (정상 회복)

```
1. ref_image_gen 진행 중 backend kill → status='running'
2. 사용자: 다음 dispatch (resume)
3. StepRunner.run(mode='resume'):
   - status='running' → 추천 1에 의해 force-like 격상
   - cleanup_artifacts() → noop (이미지 step)
   - invalidate_downstream / clear_checkpoint
   - _execute(force) → UPSERT 패턴으로 부분 진행분 보존, 누락분만 채움
   - exit verify → pass → status='completed' + snapshot 기록
4. 결과: 1 cycle 안에 회복. PNG 보존됨.
```

### 시나리오 B — 사고 회복 (PID 0bb48ebf 재현)

```
1. 사고: pytest fixture가 production DB wipe → image_asset 0 row
   (manifest.json은 archive에서 복원되어 status='completed')
2. 사용자: dispatch resume + category='image'
3. dispatcher: prerequisite 검증 통과 (status='completed' 만 봄, 현행)
4. StepRunner.run(ref_image_gen, mode='resume'):
   - status='completed' + cp 존재
   - entry verify_completion() → expected=12, found=0 → severity='missing'
   - mismatch 기록: recovery_count=1, last_recovery_reason="verify failed: 12 missing"
   - mode='force'
   - cleanup noop → invalidate → clear → execute → 12개 ref 재생성
   - exit verify → pass → status='completed' + recovery_count reset
5. composite/state_variant/outlook/chain_bg/floor_plan 동일 흐름
6. scene_image_pipeline:
   - dispatch에 ref step 미포함이었으면 cross-category auto-include (26f5f47)이 자동 추가
   - 또는 ref step이 verify 실패 후 force로 돌아 dispatch 끝나기 전 회복
7. 결과: 사용자가 사고 인지 못해도 1-2 cycle 안에 자동 회복.
   WARNING [RECOVERY] 로그 + UI recovery_count 뱃지로 진단 가능.
```

### 시나리오 C — 부분 재생성과 force 동시성 (사용자 caveat 보호)

```
[Path 1: 사용자가 특정 entity만 재생성 — UI endpoint]
   → 별도 endpoint는 step_runner 거치지 않고 직접 _execute_one(entity_id) 호출
   → 다른 entity의 ImageAsset 안 건드림 (UPSERT)
   → cleanup_artifacts 호출 안 됨

[Path 2: 사용자가 step 전체 force — category='image']
   → StepRunner.run(force)
   → cleanup_artifacts() → noop (이미지 step은 override 안 함)
   → _execute(force) → 모든 active entity에 UPSERT (없는 것만 새로 만듦)
   → 기존 데이터 보존됨

[Path 3: 두 path 동시 실행]
   → ImageAsset (project_id, entity_id, ...) UNIQUE constraint + UPSERT로 race 처리 (현행)
   → cleanup이 noop이라 한쪽이 다른쪽 데이터 지울 위험 0
```

→ **이미지 step의 cleanup_artifacts override 0건이 사용자 caveat의 직접 반영.**

### 시나리오 D — Ref 누락 (D3 핵심)

```
1. composite_image_gen partial — 일부 (char,outlook) 쌍 missing
2. scene_image_pipeline 진입:
   - check_gate (gate.incomplete_composites)이 차단 (D4 강화)
   - 또는 dispatcher 우회 경로(직접 step 호출)에서는 gate 미통과
3. 새 흐름: scene_image_pipeline._execute() shot 루프
   - resolve_refs_for_prompt → missing_refs=[composite:CXX:OYY] 반환
   - 해당 shot: WARNING + failed_count++, scene_still row 안 만듦
   - 정상 ref 있는 shot은 정상 진행 (UPSERT)
4. step 종료: failed_count > 0 → status='partial'
5. exit verify_completion → expected=N shot, found<N → final_status='partial' 확정
6. 다음 resume cycle:
   - scene_image_pipeline entry verify 실패 → mode='force' → 같은 ref 누락 상태로 또 partial
   - composite_image_gen entry verify는 자기 산출물만 검증 (cross-step dep verify는 후속 spec)
     → 만약 composite도 자체 누락이면 자기 entry verify가 잡고 force, 회복
     → composite는 정상이지만 scene_image_pipeline ref resolve가 실패하는 경우
       (예: prompt에 등장한 short_id가 entity_canon에 없음) → recovery_count 누적 → 3회 후 recovery_exhausted로 사용자 진단 필요
   - 사용자가 명시적으로 composite_image_gen도 force하면 즉시 회복
```

→ **잘못된 PNG는 절대 안 만들어짐.**

### 공통 — Snapshot diff (D2)

```
save_checkpoint(status='completed'):
   data['project_config_snapshot'] = mask(project_config)   # NEW
   data['config_hash'] = compute_config_hash(project_config) # 현행
   data['schema_version'] = manifest.schema_version          # 현행

다음 resume:
   cp_hash != current_hash:
     diff = _diff_project_config(cp.project_config_snapshot, current_config)
     # 이전: snapshot=None 폴백 → 매번 stale auto-rerun trigger
     # 이후: snapshot 존재 → 어느 key가 바뀌었는지 명시 로깅
     mode = 'force'
```

→ "매번 stale auto-rerun" 패턴 종결.

## 6. Error handling & Edge cases

| 항목 | 처리 |
|---|---|
| `verify_completion` 자체 예외 | `_safe_verify_completion`이 wrap. is_complete=False, severity='missing', metadata.crash 적재 → 안전 쪽 force-rerun |
| `cleanup_artifacts` 자체 예외 | 즉시 raise + step_run='failed' 마킹. DB inconsistent 위험 회피. |
| Recovery loop 무한 차단 | recovery_count >= 3 → `step.recovery_exhausted` AppError. 사용자 수동 진단 |
| Exit partial step의 downstream | invalidate 안 함. partial은 다음 resume에서 entry verify가 다시 잡음. `check_gate` "partial 통과" 정책 유지 (현행) |
| `.force_cleared` marker 상호작용 | auto-rerun 격상 force도 marker 작성. 정상 save 시 marker 제거 (현행) |
| Schema_version legacy 호환 | cp_schema=None → 현재 schema로 간주 (현행 `_check_cp_mismatch` 동작) + entry verify로만 검증. 첫 sweep에서 모든 step이 자기 산출물 1회 검증 |
| Snapshot 누락 cp (transition) | 첫 cycle은 diff="(legacy snapshot missing)" 로깅 + 정상 진행. 다음 cycle부터 정확한 diff |
| 빈 step (`applicable_count=0`) | verify default `is_complete=True`. override step도 expected=0이면 즉시 통과. `not_applicable`과 구분 |
| Concurrency (동시 dispatch) | step_run UNIQUE constraint + ON CONFLICT (현행). 추가 lock 도입 안 함 |
| PNG byte-level 손상 | verify는 `Path.exists()`만. byte 검증은 image_validator(D8) 별도 책임 |

## 7. Testing strategy

### Unit (`tests/core/test_integrity_report.py`, `tests/core/test_step_runner_resilience.py`)

1. CompletionReport / CleanupReport 직렬화·동등성
2. StepRunner base verify/cleanup default noop
3. `compute_config_hash` + snapshot diff
4. `_mask_sensitive_keys` (api_key/password/token/secret)

### Integration (`tests/core/test_step_runner_resilience.py`)

5. Status별 분기: completed+cp+verify pass → skip / completed+cp+verify fail → force / completed+cp=None → force / running → force-like / failed → force-like / partial → force-like / pending → force-like
6. Entry → exit 양방향 verify cycle
7. Recovery loop 한계 (3회 후 `step.recovery_exhausted`)
8. `.force_cleared` marker 상호작용
9. Cleanup 자체 예외 → step_run='failed'
10. Verify 자체 예외 → 안전 force

### Per-step verify (Phase A 6 step + scene_image_pipeline)

11. ref_image_gen.verify_completion (expected vs found, low_freq skip 반영, 파일 누락 감지)
12. composite_image_gen.verify_completion (O00 제외, char×outlook pair)
13. character_state_variant.verify_completion (staging의 dead/severely_injured 수 vs row)
14. outlook_standalone.verify_completion (outlook entity별)
15. chain_bg_render.verify_completion (planner group 수 vs row)
16. floor_plan_render.verify_completion (needs_floor_plan group 수 vs row)
17. scene_image_pipeline.verify_completion (scene_still rows + 각 shot 파일 존재)

### 부분 재생성 회귀 가드 (사용자 caveat)

18. Entity별 endpoint + 전체 force 데이터 보존 (entity Y/Z 보존 확인)
19. Shot별 재생성 + scene_image_pipeline force (다른 shot 보존)

### Runtime fail-soft (D3)

20. `resolve_refs_for_prompt` missing_refs 반환 검증
21. `scene_image_pipeline` shot skip + failed_count++ + 정상 shot은 진행
22. 다음 resume에서 회복 (partial → entry verify → force)

### Schema migration

23. alembic 003 upgrade/downgrade
24. Legacy cp 호환 (schema_version 누락 + project_config_snapshot 누락)

### 회귀 baseline

기존 1440 passed → 신규 ~50 → 약 1490 passed 목표. **회귀 0 보장.**

## 8. Phase 도입 순서

| Phase | 범위 | 산출물 | 회귀 위험 |
|---|---|---|---|
| **0. Migration** | alembic 003 (recovery_count + last_recovery_reason + variant_label 32) | DB 컬럼 + 모델 | 낮음 (additive) |
| **1. Framework** | StepRunner base 변경 (verify/cleanup hook + status별 force-like + snapshot 기록 + recovery counter + WARNING 로그 + recovery_exhausted) | step_runner.py + 신규 dataclass | 중 |
| **2. Phase A 6 step verify_completion 본체** | ref/composite/state_variant/outlook/chain_bg/floor_plan | 각 step 클래스 + schema_version bump | 낮음 |
| **3. D3 runtime fail-soft** | resolve_refs_for_prompt + scene_image_pipeline shot skip + scene_image_pipeline.verify_completion | scene_reference_service + scene_image_pipeline | 중 |
| **4. D4 gate 강화** | pipeline_gate.check_scene_images_ready 자산 4종 추가 | pipeline_gate | 낮음 |
| **5. UI 가시성** | recovery_count 뱃지 + last_recovery_reason 툴팁 + recovery_exhausted 모달 | frontend | 낮음 |

### PR 단위

- **PR 1**: Phase 0 + 1 (framework + migration) — 핵심 기반
- **PR 2**: Phase 2 (per-step verify 6건) — verify contract 본체
- **PR 3**: Phase 3 + 4 (D3 + D4) — runtime + gate
- **PR 4**: Phase 5 (UI)

각 PR 듀얼 리뷰(Codex + Claude). PR 1 완료 후 PID 0bb48ebf 즉시 force-rerun으로 시나리오 B 재현 확인. PR 2까지 완료 시 완전체.

## 9. 후속 spec (이번 spec 미포함)

- **D5**: `ImageAsset.entity_id` Text → FK + `ondelete='SET NULL'` (FK migration 영향 큼)
- **D7**: opik trace flush 보장 (asyncio destroy 시 explicit flush hook)
- **D8**: image_validator OpenAI Vision temperature 미지원 fix (gpt-5.5 family temperature 제외 패턴)
- **Cross-step dep verify**: consumer step이 producer 산출물도 entry에서 검증 → 시나리오 D 회복 자동화
- **Phase B/C verify_completion**: scene_consistency / scene_detail / analysis category 23 step

## 10. 핵심 결정 요약

1. **Scope**: D1+D2+D3+D4+D6 (D5/D7/D8은 후속)
2. **검증 실패 정책**: 자동 force-rerun (silent recovery) + 가시성 보강 (recovery_count + last_recovery_reason + WARNING 표준)
3. **Cleanup 책임**: StepRunner 통일 hook, default noop, 이미지 step은 거의 모두 noop 유지 (사용자 caveat)
4. **검증 시점**: 양방향 (resume entry + execute exit), 호출 site는 `StepRunner.run()` 한 메서드 내 2시점
5. **Runtime fallback**: fail-soft (shot skip, 잘못된 PNG 안 만듦)
6. **Status별 일관**: completed-with-valid-cp 외 모든 상태는 force-like 처리 (cp 없는 completed / running / failed / partial / stale / pending)
7. **Recovery loop 한계**: 3회 후 `step.recovery_exhausted` → 사용자 수동 진단
8. **Schema_version**: verify contract 변경 시 step_manifest.schema_version bump → cp mismatch 시 자동 force
