"""앱 설정 — Pydantic v2 BaseSettings.

W2-F16: class-based Config → model_config ConfigDict 전환.
W2-F17: ENVIRONMENT=prod에서 insecure default 거부 (fail-fast).
"""
from pathlib import Path
from typing import Literal

from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

# 프로젝트 루트: backend/ 의 부모 디렉토리
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent


# dev/test는 편의성 우선 (insecure default 경고만), prod는 fail-fast.
_INSECURE_SECRET_KEYS = ("", "dev-secret-key")
_INSECURE_PASSWORDS = ("", "admin123", "creator123")
# 현재 dev DSN — prod에서는 명시적으로 외부 DSN을 요구한다 (Codex W2 High 3).
_DEV_DEFAULT_DSN = "postgresql://theroad:theroad_dev_2026@localhost:5432/theroad"


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")

    app_name: str = "TheRoad Scene Lab"
    # W2-F17: dev/test/prod 분기. prod일 때만 insecure defaults를 fail-fast.
    environment: Literal["dev", "test", "prod"] = "dev"
    secret_key: str = "dev-secret-key"
    database_url: str = "postgresql://theroad:theroad_dev_2026@localhost:5432/theroad"
    projects_dir: str = str(PROJECT_ROOT / "projects")
    session_ttl_hours: int = 24
    default_admin_username: str = "admin"
    default_admin_password: str = "admin123"
    default_creator_username: str = "creator"
    default_creator_password: str = "creator123"
    openai_api_key: str = ""
    # 보조 OpenAI 키 슬롯 (2026-07-30). 1차 키가 **키 수준 실패**
    # (billing hard limit / quota 소진 / 401·403 인증)를 내면 이 키로
    # 전환한다. ""(default)=슬롯 1개=기존 동작과 동일. 전환 규칙과 트리거
    # 경계는 `app/core/openai_keys.py` 참조 — 일반 4xx·5xx·타임아웃·단순
    # rate limit 은 전환 대상이 아니다(기존 재시도 경로 유지).
    openai_api_key_secondary: str = ""
    # ★2026-09-08 사용자 지시: GPT 텍스트는 **전부** gpt-6-astra.
    #  기본(`gpt` alias)은 reasoning effort **medium**, 나머지 alias 는 **low**.
    #  실측 확인: OpenAI 직결로 서빙된다(`gpt-6` · `gpt-6-astra-pro` 는 404,
    #  이것만 200) · `reasoning_effort` 를 medium/low 둘 다 받는다.
    #  이미지(`gpt-image-2`)는 그대로다 — 지시 범위가 텍스트뿐이다.
    openai_model: str = "gpt-6-astra"
    #: 기본 alias(`gpt`)가 쓰는 추론 강도.
    openai_reasoning_effort: str = "medium"
    #: 보조 alias(terra·luna·nano)가 쓰는 추론 강도.
    openai_reasoning_effort_aux: str = "low"
    #: VLM 판정(고르는 자리·결함 관찰)이 쓰는 추론 강도 — **high**.
    #: ★2026-09-08 사용자 지시: 「vlm 판단 부분도 grok 대신 gpt-6-astra 로
    #:  하고 전부 high 로」 「즉, gemini + gpt-6-astra(high)」
    openai_reasoning_effort_judge: str = "high"
    gemini_api_key: str = ""
    # Pro 는 `gemini-3.1-pro-preview` 가 **여전히 최신**이다 (2026-08-27 확인).
    # 5~8월 changelog 에 그보다 새 Pro 가 없고 GA(접미사 없는 `gemini-3.1-pro`)
    # 도 아직 없다. Gemini 3.5 Pro 는 세 번 미뤄져 미출시 — 그래서 안 바꾼다.
    gemini_text_model: str = "gemini-3.1-pro-preview"
    # ★2026-09-08: Flash 를 3.7 → 3.8 로 올렸다 (사용자 지시 「3.8로 올려」).
    #  Pro 는 그대로 둔다 — 사용자가 「3.1-pro 는 그대로 두고」라고 못박았다.
    #  올리기 전에 실제 API 로 확인했다(무료 한 마디):
    #    gemini-3.8-flash → 200 · modelVersion=gemini-3.8-flash
    #  종전 3.7(08-13 GA)도 살아 있지만 최신을 쓴다. 앞 판 기록은 그대로 둔다 —
    #  3.5(05-19 GA) → 3.6(07-21 GA) → 3.7(08-13 GA) → 3.8.
    #  ★이 값을 바꾸면 `llm_client.AVAILABLE_MODELS` 의 UI 라벨도 같이 바꾼다.
    #   `tests/unit/test_model_alias_labels.py` 가 어긋나면 세운다.
    gemini_flash_model: str = "gemini-3.8-flash"
    gemini_lite_model: str = "gemini-3.8-flash"
    # ★2026-08-27: preview → GA. 종전 `gemini-3.1-flash-image-preview` 는
    #  **죽은 ID** 였다 — 2026-02-26 preview 출시, **05-28 GA 승격과 동시에
    #  deprecated**, **06-25 shutdown 예정**. 그런데 실측으로는 그 뒤로도
    #  성공 12,735 / 실패 491 이고 실패 사유가 검열·429 뿐이라 「모델 없음」이
    #  하나도 없다 — GA 로 자동 승계돼 이름만 죽은 채 돌고 있었다.
    #  deprecated ID 에 기대지 않는다.
    #
    # ★pro(`gemini-3-pro-image`)는 **안 쓴다** (사용자 결정). 참조 한도가
    #  우리 구조와 어긋난다 — flash 는 사물 10 + 인물 4 + **스타일 3** 인데
    #  pro 는 사물 6 + 인물 5 이고 스타일 슬롯이 없다. 우리는 콘티를 스타일
    #  참조로 넣는다.
    gemini_image_model: str = "gemini-3.1-flash-image"
    # OpenAI 이미지 모델 (2026-08-03 사용자 결정 — 배경 씨드 전면 전환).
    # 근거=6그룹 맞대결. 같은 저작 프롬프트·같은 형태 참조 사진으로 그림
    # 모델만 바꿔 그리고, 참조 앞에서 좌우를 바꿔 두 번 물어 **두 번 다 이긴
    # 쪽만** 승자로 셌다 → gpt-image-2 4승 · 무 2 · 기존 0승. 특히 갈린 축은
    # 사람이 표면에 얹은 시각물(여러 위치·크기의 한 체계, 부속 표시, 진열
    # 밀도)과 해상도(1536×1024 vs 1376×768).
    openai_image_model: str = "gpt-image-2.5-sunburst"
    # ── Anthropic (2026-08-06 사용자 지시 — 후보 선정 판정 모델 교체) ──
    # 선정 판정이 방향·공간·엔티티 동일성을 못 읽는 실측(16샷 전수 육안 대조
    # 에서 VLM 과 일치 1건)이 근거. 키가 비면 alias 자체를 Router 에 등록하지
    # 않고 선정 판정은 기존 `gemini-pro` 로 남는다 — 키 없이 배포해도 기존
    # 경로가 그대로 돈다(fail-open).
    # ★Claude Opus 5 는 `temperature`/`top_p`/`top_k` 를 거부(400)하므로
    #  alias 를 `_NO_TEMPERATURE_ALIASES` 에 함께 등록해야 한다.
    # ★thinking 이 기본 ON 이고 `max_tokens` 가 thinking+본문을 함께 덮는다 —
    #  `llm_max_output_tokens`(65536) 가 그 여유를 이미 갖고 있다.
    anthropic_api_key: str = ""
    anthropic_judge_model: str = "claude-opus-5"
    # 비교용 상위 모델 (alias `claude-fable`). 판정 배선에는 쓰지 않고
    # 실험에서만 부른다. ★Fable 은 thinking 이 **항상 ON** 이라
    # `thinking` 파라미터 자체를 보내면 400 이고, 30일 데이터 보존이
    # 요구된다(ZDR 조직은 전 요청 400). sampling 파라미터 거부는 Opus 와 동일.
    anthropic_fable_model: str = "claude-fable-5"
    # ★후보 선정 판정을 Opus + GPT-5.6 Sol **이중**으로 (2026-08-06 확정).
    # 근거=4모델 대조 실측. 핸들이 이중으로 겹친 이미지에 Opus·Fable 은
    # "1개"라 답했고 Sol 만 2,2 로 맞혔다(대조군 1,1 — 과탐지 아님).
    # Gemini 는 그 대조군에서 2개, 손이 쥔 폰에 "닿은 것 없음"이라 답해 제외.
    # OFF 로 내리면 Opus 단독. ANTHROPIC_API_KEY 가 없으면 이 값과 무관하게
    # 기존 `gemini-pro` 단독으로 남는다(fail-open).
    multiroll_dual_select_judge_enabled: bool = True
    # ── G+Q 판정 체계 (2026-08-10 사용자 확정 — 설계 SOT:
    # docs/superpowers/specs/2026-08-10-dual-judge-selection-redesign-design.md) ──
    # ON: 선정·fix-rejudge = Gemini+Qwen 동시 판정(winner 일치 또는 Qwen 눈
    # 격차<0.2 → Gemini 채택, 이상이면 합산 합의), 수정 = Qwen 관찰 → Gemini
    # 취합 → nb2. Opus/GPT 는 경로에서 빠진다.
    # ★기본 OFF = 기존 경로·지문·config_hash byte-identical — 완료 산출
    # (금월도 256샷)의 지문을 움직이지 않는 것이 이 기본값의 계약이다.
    # ON+DASHSCOPE 키 없음 = fail-closed(조용한 Gemini 단독 강등 금지 —
    # 강등이면 "이중으로 판정했다"는 기록이 거짓이 된다).
    multiroll_gq_judge_enabled: bool = False
    # DashScope OpenAI 호환 엔드포인트 (Qwen VLM 심판). Router 를 타지
    # 않는 이유: `json_schema` 미지원(`json_object` 만) — 스키마는
    # qwen_vlm_client 가 로컬 검증한다.
    dashscope_api_key: str = ""
    dashscope_base_url: str = (
        "https://dashscope-intl.aliyuncs.com/compatible-mode/v1")
    qwen_vlm_model: str = "qwen3.8-max"
    # ── confined(좁고 복잡한 조작 공간) 샷 fp readback 경로 (2026-08-10
    # 설계 SOT: docs/superpowers/specs/2026-08-10-confined-interior-fp-readback-production-design.md) ──
    # ON: confined_structure=True 샷이 [gpt-image-2 정밀 fp 작화 → VLM
    # readback 검증·장면 설명 → fp i2i 국소 수정 → fp+설명 첨부 생성]
    # 경로를 탄다(기존 bgfirst 2택1·geom_authority 계약 대체). 자동차
    # 전용 아님 — 콕핏·장갑차·버스·항공기 등 조작 장치 많은 밀폐 공간
    # 전부(공간 실체는 브리프가 정함, 계약은 범용 어휘).
    # ★기본 OFF = 기존 경로·지문·config_hash byte-identical(완료 산출
    # 동결). 서비스 배선 전까지는 부품(confined_fp 모듈·팩)만 존재한다.
    still_confined_fp_enabled: bool = False
    # ── 프롬프트 조립 순서 재배열 (축 A1, 2026-08-15) ──
    # 콤마로 구분한 스텝 이름 목록. 목록에 든 스텝만 user 프롬프트를
    # [정적 지시 → 프로젝트 단위 가변 → 호출 단위 가변] 순서로 조립한다.
    # role 이동·문구 수정·조각 삭제 없음 — 같은 role 안에서 순서만 바뀐다.
    # ★기본 빈 문자열 = 기존 조립과 byte-identical(발송 지문 무변화).
    # 효과 판정 SOT 는 `tools/opik_prompt_audit/cache_baseline.py` 로 잰
    # 재배열 전/후의 cached_tokens 다 — 문헌 수치를 기대값으로 쓰지 않는다.
    prompt_assembly_reorder_steps: str = ""
    fal_key: str = ""
    fal_ai_enabled: bool = False           # fal.ai 앵글 적용 활성화 여부
    background_chain_enabled: bool = False # background_chain 결과를 scene 이미지 생성 ref로 inject 활성화 여부 (P0-2). False면 체크포인트가 있어도 무시
    shot_essence_enabled: bool = False     # shot_essence_extraction step 활성화 여부 (Phase 1b). False면 not_applicable, scene_detail consumer 통합은 Phase 2에서 추가 예정
    chain_bg_guide_enabled: bool = False   # Phase 2: chain_bg_render LLM이 출력한 shot_guides를 scene_detail user_prompt에 prepend. False면 prepend 0 (회귀 보장)
    chain_bg_camera_meta_enabled: bool = True   # Phase 9.1: chain_bg.camera_recommendations(floor_plan_prompt가 출력한 camera_position/height/lens/framing)를 scene_detail user_prompt에 prepend하여 카메라 일관성 룰 적용. False면 prepend 0.
    # 3a fix (2026-07-01, Codex 합의): background_render depth-1 star anchor. 배경
    # 마스터플랜이 sub_location=None 방출 → depends_on_bg 체이닝 미발동 → 같은 위치
    # 배경들이 서로 i2i 참조 없이 fp에서 독립 생성(시각 일관성↓ + 캔버스 배경 엣지 0).
    # ON 이면 같은 location_id 그룹의 anchor(첫 bg)를 나머지 bg 가 depends_on_bg 로
    # 참조(depth-1 star, 무한체인 drift 회피)하고 input_image_ids 에 anchor UUID 기록.
    # ★기존 depends_on_bg 가 있으면 preserve(empty 일 때만 inject). default OFF =
    # plan/render/input_image_ids byte-identical.
    # ★(2026-07-01 정정) 이 flag 는 **생성 정책**(legacy w18j_overlap 경로에서만
    # depends_on_bg 주입) 전용으로 dormant 유지. shot_aware_plan 경로엔 영향 없음.
    background_render_star_anchor_enabled: bool = False
    # 3a recording (2026-07-01, Codex NARROW — 생성/기록 flag 분리): shot_aware_plan
    # 경로가 이미 첨부한 prior_bg(attached_reference_lineage.prior_bg_ids)를 background_
    # render ImageAsset.input_image_ids 에 [fp, *prior] 로 기록해 캔버스 배경↔배경 엣지
    # 복원. **기록 정책만** — 생성/depends_on_bg 무변경(과동질화 리스크 0). default OFF
    # = input_image_ids fp-only(현행) byte-identical.
    background_render_record_prior_bg_lineage_enabled: bool = False
    # Phase 4 iter 7 W1 — LVM (vision validation) 호출 실패 시 정책.
    # default False: AppError fail-fast (silent absorb 차단). True: explicit
    # operator override — partial validation_unavailable 으로 진행 허용 (운영
    # 추적용 marker `_validation_unavailable=True` 동반). LVM 호출 자체가
    # working 한 상태에서는 영향 0.
    allow_lvm_validation_unavailable: bool = False

    # Phase 4 iter 7 follow-up — Scene LVM cost policy.
    # full: 모든 scene image LVM (옛 default).
    # targeted: SCENE_LVM_TARGETED_SHOT_IDS 에 명시된 (scene_index)_(shot_index) 만 LVM.
    # sample: SCENE_LVM_SAMPLE_RATE (0.0~1.0) 확률로 LVM.
    # off: scene LVM 모두 skip (default — 비용 절감).
    # ref_only: 의미적으로 off 와 동일 (ref_image_pipeline 만 LVM, scene 은 skip).
    # skip 시 severity="not_run_cost_policy" + matches_prompt=False marker 기록 (silent
    # success 금지 — feedback_no_silent_fallback). ref_image_pipeline 은 영향 0.
    scene_lvm_validation_mode: Literal["full", "targeted", "sample", "off", "ref_only"] = "off"
    scene_lvm_targeted_shot_ids: str = ""  # 콤마 구분, 예: "1_3,5_2,6_2,7_5,8_4,15_3,15_5,22_2"
    scene_lvm_sample_rate: float = 0.0     # 0.0~1.0 (sample 모드 전용)
    background_mode: Literal["off", "on", "floor_plan_anchored", "chain_only"] = "off"
    # Phase 7: "on" — Phase 7 6 step 활성. "floor_plan_anchored" — legacy alias for "on".
    # "off" — 모든 background step disabled. "chain_only" — Phase 4 LEGACY only.
    opik_api_key: str = ""
    # 셀프 호스팅 Opik 주소 (2026-08-14) — 예: http://192.168.133.87:5173/api
    # (직접 접속 — 중계기 안은 기각·제거됨). 비면 클라우드 기본.
    opik_url_override: str = ""
    opik_workspace: str = "default"
    opik_project_name: str = "theroad-scene-lab"

    # Pipeline v3 요약 길이 설정
    max_episode_summary_length: int = 500
    max_scene_summary_length: int = 200
    max_project_summary_length: int = 500

    max_concurrent_image_gen: int = 15       # 이미지 동시 생성 수 (Gemini API 기준)
    max_concurrent_variation: int = 10      # 변형 추천/생성 동시 수
    max_concurrent_entity_detail: int = 10  # 요소 상세 추출 동시 수
    # ── v3 Legacy ──
    scene_variation_count: int = 2          # 씬당 T2I 변형 수 (v3, shot 데이터 없을 때 fallback)

    # ── v4 Shot 기반 파이프라인 ──
    shot_variation_count: int = 1          # shot당 T2I 변형 수 (SHOT_VARIATION_COUNT)
    episode_max_shots: int = 0             # 에피소드 최대 shot 수, 0=무제한 (EPISODE_MAX_SHOTS)
    shot_selection_enabled: bool = False   # False=모든 shot 선택, True=LLM이 중요 shot 선정 (SHOT_SELECTION_ENABLED)
    scene_detail_llm: str = "gpt"          # 씬 상세 분석 LLM: "gemini" or "gpt"
    scene_segment_context_chars: int = 100  # 씬 세그먼트 앞뒤 컨텍스트 글자수

    # LLM 토큰 설정 — 1M context 모델 기준
    # ★[2026-09-09] 65,536 → 128,000. 이것은 **요청값**이고, 모델마다
    #  `_provider_max_output` 이 그 모델의 실제 최대로 잘라 준다
    #  (gpt-6-astra 128,000 · Gemini 65,536 · gpt-4.1 32,768 · grok 8,000).
    #  앞 판은 이 하나로 전부 재서 gpt-6-astra 는 **절반만** 쓰고
    #  gpt-4.1 은 최대를 **넘겨** 보냈다.
    llm_max_output_tokens: int = 128000    # 최대 출력 토큰 (GPT/Gemini 공통)

    # Block B B2 (resume architecture, plan v2.1.3 / spec V5): running step_run row
    # 가 stale 로 분류되는 elapsed 임계값 (초). `_evaluate_running_state()` 가
    # started_at 으로부터 이 값을 초과한 row 를 STALE_RUNNING_RECOVERY 로 분류
    # → atomic claim 으로 expected-match steal 시도 (Block C). default 1h.
    step_running_timeout_seconds: int = 3600

    # ── 스텝 락 소유자 신원·하트비트 (2026-08-26) ──
    # 위 `step_running_timeout_seconds` 는 이제 **마지막 그물**이다. 소유자
    # 신원이 있는 행은 `step_lock.judge_owner()` 가 죽음을 확인해 즉시 회수하고,
    # 신원이 없는 구 행에서만 경과 시간으로 떨어진다.
    #
    # lease: 하트비트가 이만큼 안 뛰면 죽은 것으로 본다. 같은 호스트면 PID 를
    # 직접 물어보므로 이 값이 안 쓰이고, **다른 호스트이거나 PID 재사용이
    # 의심될 때만** 쓰인다.
    # interval: 하트비트를 뛰는 주기. lease 의 1/3 이하를 권한다 — 한두 번
    # 놓쳐도 lease 가 안 만료된다.
    step_lock_lease_seconds: int = 90
    step_lock_heartbeat_seconds: int = 30
    # 하트비트 만료를 **죽음으로 확정**할지. 기본 꺼짐.
    # 하트비트가 멈춘 것은 죽음의 증거가 아니라 정황이다 — DB 가 잠깐 끊겨도,
    # 프로세스가 멈춰 있어도 멈춘다. 그 상태에서 락을 뺏었는데 원래 일하던
    # 쪽이 살아 돌아오면 둘이 같은 산출물에 쓴다. 산출물 쓰기를 막는 울타리가
    # `checkpoint_gate` 한 겹뿐이므로, 자동 회수는 **확정 사망**(같은 프로세스
    # 등록부 부재, 로컬 PID 소멸)에만 허용한다. 여러 대로 늘려 fencing 을
    # 갖춘 뒤에 켠다.
    step_lock_heartbeat_steal_enabled: bool = False
    # 기동 시 이 호스트의 죽은 프로세스가 잡은 락을 회수한다. 끄면 예전처럼
    # 경과 시간을 기다려야 한다.
    step_lock_startup_reclaim: bool = True

    # ── problems.md #6/#13 toggles (review I1) ──
    prompt_version_pack_strict: bool = False  # True 시 module 안 stem 이 latest version pack 에 없으면 RuntimeError. default False (lenient observability).
    llm_local_schema_validate: bool = True    # call_structured/call_multiturn 응답에 jsonschema.validate 추가 검증. default True (Tier 1/2/3 fallback 통합).

    # ── W19A floor_plan_prompt v5 → v6 opt-in selector ──
    # "5" (default): production behavior unchanged — resolves v5 pack
    # (5.202605201406) and skips the v6-only base_layer_decision validator.
    # "6": opt-in to the W19A v6 pack (6.YYYYMMDDHHmm) which adds the
    # base_layer_decision partition tag per numbered marker and removes the
    # legacy plot_device category. Hard flips are explicitly rejected: callers
    # remain on v5 unless they opt in by setting this selector.
    # "7" (W21B-wave-4): v6-compatible fidelity pack — schema/user_template
    # byte-identical to v6, system.md adds Rule 11 self-fidelity wording.
    # "8" (W21B-wave-5): v6-compatible space-type pack — schema byte-identical,
    # system.md adds space-type adaptation (interior/exterior/transition/site/
    # open_safe) and user_template adds {space_type_block}. Output contract
    # unchanged. All v6-family validators / consumer gates accept {6,7,8}.
    # "9" (W21B-wave-5): v6-compatible scale-fidelity pack — schema/user_template
    # byte-identical to v8, system.md adds the Spatial scale fidelity section and
    # folds scale-fidelity into Rule 11 (over-scale / over-rooming fix). Keeps
    # v8's space-type relay (orthogonal). Output contract unchanged; v6-family
    # validators / consumer gates accept {6,7,8,9}.
    # "10" (W21B scale-guideline): v6-based pack whose schema is an additive
    # superset — adds REQUIRED ``scale_guideline`` (storey count / approx
    # height·footprint / opening counts / full stair extents; evidence-bound,
    # otherwise era-region-typical range marked typical). Flat top-down plans
    # carry no vertical scale, so downstream volumetric conversion collapsed
    # buildings to one storey. Existing v6-family fields unchanged; consumer
    # gates accept {6,7,8,9,10}.
    floor_plan_prompt_version: Literal["5", "6", "7", "8", "9", "10", "11"] = "5"

    # ── W19B-2 / W21B background_prompt v6 → v7 → v8 → v9 → v10 opt-in selector ──
    # "6" (default): production behavior unchanged — resolves v6 pack
    # (6.202605091200), keeps the existing flat numbered_elements path, and
    # does not require the floor_plan_overlay_payload checkpoint.
    # "7": opt-in to the W19B-2 v7 pack which consumes the W19B-1
    # floor_plan_overlay_payload checkpoint and separates base markers
    # (layout reference) from transient overlay markers (prose only). Hard
    # flips are rejected: callers remain on v6 unless they opt in by setting
    # this selector AND have floor_plan_prompt_version ∈ {"6","7"} upstream
    # (v7 = v6-compatible fidelity pack, W21B-wave-4).
    # "8" (W21B-wave-1): opt-in to the v8 pack (8.202605282046) which keeps
    # the v7 overlay payload SOT (overlay_payload validation is still
    # required for opt-in v8 runs) but withholds transient overlay markers
    # and scene_segments verbatim text from the prompt body entirely. The
    # user_template drops both ``{transient_markers_block}`` and
    # ``{scene_segments_block}`` placeholders; only base markers, camera
    # recommendation, applies_to_shots, visual_world_rules and the source
    # language surface to the prompt. system.md rewrites the anti-
    # stylization checklist (forbids CGI / concept art / luxury showroom /
    # stock-photo perfection; allows restrained film grain / desaturated
    # genre color / anamorphic lens behavior) and enforces BG-only purity
    # (no humans / body parts / fresh wounds / current-shot action /
    # photo-handling moments).
    # "9" (W21B-wave-2): opt-in to the v9 pack (9.202605290248), preserving
    # v8 BG plate purity while surfacing surface_role and adding a
    # screen/display content guard. Output JSON schema is unchanged from v7
    # so background_prompt_step.SCHEMA_VERSION stays at 3 and existing
    # v6/v7 checkpoints remain valid. Hard flips are rejected; callers
    # remain on v6 unless they opt in.
    # "10" (W21B-wave-2.1): opt-in to the v10 pack (10.202605290451),
    # preserving v9 surface_role / fp-less plate behavior while forcing
    # screen, display, TV, monitor and photo-frame internals to blank / off /
    # glare / static / unreadable-only.
    # "12" (W21B-wave-4 C4): opt-in to the v12 pack (12.202605310513) — v10
    # contract + a projection-card plate block. The step loads the
    # shot_aware_bg_render_plan + shot_projection_card checkpoints and, per bg,
    # injects the verified anchor plate prose only on the guard's pass path
    # (every fallback_reason stays v10-faithful).
    background_prompt_version: Literal[
        "6", "7", "8", "9", "10", "11", "12", "14"
    ] = "6"

    # ── W19B-3 / W20C / W20E7-A background_render reference graph selector ──
    # "legacy" (default): production behavior unchanged — DAG level
    # ThreadPool render path with FP-then-prior-BG ref concat. _config_hash
    # payload byte-identical to today (this selector key is NOT stamped on
    # the legacy branch). bg_reference_catalog is not produced.
    # "w18j_overlap" (W20E7-A DEPRECATED — deprecated_code_reference_planner):
    # legacy env / config compatibility value only. The W19B-3
    # deterministic reference planner module has been deleted; setting
    # this value now causes ``background_render`` to emit every
    # renderable bg as ``failed`` with a stable deprecation error
    # (``w18j_overlap_deprecated`` /
    # ``background_render_reference_mode_deprecated``). Zero image
    # calls, zero deterministic reference decisions. The enum value is
    # retained ONLY so existing env / settings files that still carry
    # the string don't trip Pydantic Literal validation, and so the
    # ``_selector_guards`` / ``shot_aware_bg_preflight`` mutex guards
    # (which still fire when w18j_overlap + W20B are both flipped on)
    # keep working. New runs MUST use "shot_aware_plan" instead.
    # "shot_aware_plan" (W20C): opt-in path that consumes the W20B
    # ``shot_aware_bg_render_plan`` checkpoint (LLM-emitted dwelling-scoped
    # reference graph + per-bg camera decisions). The step walks the plan's
    # graph nodes by ``node_index`` and renders each bg with
    # ``render_one_background(..., max_attempts=1)`` using the references
    # the LLM materialized (FP for anchor; catalog png(s) for non-anchor).
    # The plan must be ``status='ok'`` AND ``production_clear=True`` per
    # fp_id; otherwise the bgs under that fp are emitted as failed /
    # not_applicable with zero image calls. This is the sole active
    # opt-in code-reference path after W20E7-A retirement of
    # ``w18j_overlap``.
    # Hard flips rejected; callers remain on "legacy" unless they opt in.
    background_render_reference_mode: Literal[
        "legacy", "w18j_overlap", "shot_aware_plan"
    ] = "legacy"

    # ── TASK3-B: missing-shot_aware_plan direct-plate fallback ──
    # When ``background_render_reference_mode='shot_aware_plan'`` an
    # fp-anchored bg whose ``shot_aware_bg_render_plan`` entry is MISSING
    # (``plan is None`` — e.g. the upstream geometry readback transiently
    # failed for that fp and the plan step dropped it) is otherwise emitted
    # as ``failed`` with zero image calls, which turns a single dropped fp
    # into a ``partial`` background_render and cascades to a
    # ``scene_image_pipeline`` STALE_UPSTREAM block for the WHOLE episode.
    # True (default): such bgs degrade to the existing direct-plate render
    # path (text_only + prior-bg refs, the same path site-surface bgs use)
    # WHEN the direct-plate inputs are present (a non-empty t2i_prompt).
    # The degraded entry is explicitly marked
    # (``shot_aware_plan_mode='missing_plan_direct_plate_fallback'`` +
    # ``render_degraded=True`` + ``fallback_reason='shot_aware_plan_missing'``
    # + ``missing_fp_id``) — visible graceful degradation, never a silent
    # fidelity loss. A bg with no direct-plate inputs stays ``failed``. This
    # is a structural gate ONLY (plan-missing + renderable), never keyed on
    # location name / kind. False: the legacy fail-closed behaviour (every
    # bg under the missing fp emitted ``failed``). The ``plan present but
    # not production_clear`` case is unaffected — it always fails closed.
    background_render_missing_shot_aware_plan_fallback_enabled: bool = True

    # ── W20A base location dossier opt-in selector ──
    # False (default): BaseLocationDossierStep returns not_applicable;
    # nothing in the W19 path or downstream consumers changes — bytewise
    # safe against existing checkpoints.
    # True: the step produces a per-fp_id dossier artifact that captures
    # the dwelling's five identity axes (structure / materials / fixed
    # elements / standard-of-living / lighting), an FP geometry candidate
    # shape (camera-cell / look-at-cell / visible-units / visible-openings
    # slots), a VLM 10x10 readback gate (synthetic_placeholder ONLY in
    # this wave — real VLM provider wiring is deferred to a future
    # approved wave), and the anchor candidate surface (the actual anchor
    # selection is W20B / LLM-owned per the W19J pivot — code never picks
    # anchor). Requires floor_plan_prompt_version ∈ {"6","7"} upstream
    # (v7 = v6-compatible, W21B-wave-4); otherwise
    # the step is not_applicable even when enabled.
    base_location_dossier_enabled: bool = False

    # ── W20A2 floor_plan_geometry_readback opt-in selector ──
    # False (default): FloorPlanGeometryReadbackStep returns
    # not_applicable; the W19 / W20A path is unchanged.
    # True: per fp_id in the dossier checkpoint, the step builds a
    # **synthetic_fixture** readback (deterministic placeholder marker
    # cells — NO real VLM call in this wave), computes geometry
    # candidates (camera-cell / look-at-cell candidates per unit,
    # direction-vector records, view-cone records keyed on a generic
    # lens enum, visible-unit / visible-opening supersets, wall/door
    # invalidation diagnostics), renders a review HTML overlay, and
    # writes it under the checkpoint directory at
    # ``review_html/<fp_id>.html``. Real VLM provider wiring is NOT in
    # this wave; the module exposes a ``vlm_provider`` callable slot
    # that a future wave can pass in, behind explicit approval.
    # Requires ``base_location_dossier_enabled=True`` and
    # ``floor_plan_prompt_version`` ∈ {"6","7"} upstream (v7 =
    # v6-compatible, W21B-wave-4).
    floor_plan_geometry_readback_enabled: bool = False

    # ── W20A2.5 real VLM readback provider opt-in selector ──
    # False (default): the step's vlm_provider resolution returns None
    # and compute_readback emits the synthetic_fixture path. Zero
    # external API calls in this branch — production safe.
    # True: the step resolves to ``litellm_vlm_provider`` (W20A2.5
    # production-adjacent helper). The helper itself currently raises
    # NotImplementedError because the actual litellm request body is
    # intentionally deferred to a future wave behind explicit user /
    # Codex approval; flipping this flag without that follow-up wave
    # leaves the step fail-closed at the call site rather than
    # silently issuing real API traffic. The helper's lazy ``import
    # litellm`` keeps the dependency out of the core geometry
    # module's import graph.
    floor_plan_vlm_readback_real_provider_enabled: bool = False

    # ── #92 6단계 (2026-08-27): 도면을 **두 모델로** 읽는다 ──────────
    # 사용자 지시 「무조건 gemini 3.1 pro 와 grok 최신 모델 둘」.
    #
    # ★ON 이면 도면마다 **두 번 산다**(실측 도면당 약 $0.09). 그래서
    #  기본 OFF 다 — 켜는 것은 지출 결정이다.
    #
    # ★하류가 쓰는 값은 **첫 모델 것 그대로**이고, 이 플래그가 보태는
    #  것은 `readback["agreement"]` 등급 한 칸뿐이다. 실측이
    #  「`exact` 를 요구하면 안 된다」고 말했다 — 도면 8장·마커 111개에서
    #  ID·kind 는 100% 일치인데 **셀 정확 일치는 37%** 였고,
    #  `fp_l02_dining_hall` 은 14개 중 1개만 같았다. 값을 고르거나
    #  버리는 계약을 넣으면 그 도면 하나로 파이프라인이 선다.
    floor_plan_vlm_readback_dual_enabled: bool = False

    # ── TASK3-A: floor_plan_geometry_readback per-fp bounded retry ──
    # The real VLM readback (``litellm_vlm_provider``) is a single
    # ``litellm.completion(num_retries=0)`` call per fp; a transient,
    # stochastic provider error (e.g. a duplicate marker NUMBER that trips
    # ``validate_provider_output``) drops that fp's readback with no retry,
    # which cascades: the shot_aware plan step excludes the fp (no geometry
    # block), background_render fail-closes, scene_image_pipeline blocks.
    # This selector adds a per-fp bounded retry in the STEP loop (a distinct
    # layer from the provider's ``num_retries=0`` per-call audit lock): on a
    # ``GeometryReadbackError`` the step re-invokes ``compute_readback`` up to
    # this many extra times. Each retry is a fresh counted provider call, so
    # ``real_vlm_call_count`` stays honest. Retry only applies when a real
    # provider is resolved (the synthetic-fixture path is deterministic — a
    # retry would fail identically, so it is skipped to keep the default-OFF
    # payload byte-identical). 0 = legacy single-attempt behaviour.
    floor_plan_geometry_readback_max_retries: int = 2

    # ── W21B-wave-4 floor_plan_semantic_readback opt-in selector ──
    # False (default): FloorPlanSemanticReadbackStep returns
    # not_applicable; no marker-semantic fidelity gate runs.
    # True: per fp_id in the dossier checkpoint, the step builds a
    # marker-SEMANTIC readback (does the image content at each base
    # marker match the expected object class / label?) and a fail-closed
    # gate (pass / needs_fix / needs_review / synthetic_unverified). With
    # the real-provider selector OFF (default) the step uses the
    # synthetic fixture path — deterministic placeholder verdicts, NO
    # real VLM call. This is a SEPARATE gate from the W20A2 geometry
    # readback (which checks marker number/cell/base-kind); semantic
    # fidelity carries its own cost / failure / acceptance policy.
    # Requires ``base_location_dossier_enabled=True`` and
    # ``floor_plan_prompt_version`` ∈ {"6", "7"} upstream.
    floor_plan_semantic_readback_enabled: bool = False

    # ── W21B-wave-4 real semantic VLM provider opt-in selector ──
    # False (default): the semantic step's provider resolution returns
    # None and the synthetic fixture path is used. Zero external API
    # calls. True: the step resolves to ``litellm_semantic_vlm_provider``
    # (W21B-wave-4 helper). Flipping this flag is what enables a real
    # vision-LLM call per fp_id; it must stay OFF outside an explicitly
    # approved canary. The helper's lazy ``import litellm`` keeps the
    # dependency out of the core module's import graph.
    floor_plan_semantic_readback_real_provider_enabled: bool = False

    # ── W21B-wave-4 shot_projection_card opt-in selector (C2) ──
    # False (default): ShotProjectionCardStep returns not_applicable; no
    # projection card is produced. True: per (bg_id, shot_id) target (one
    # card per shot in the background's applies_to_shots), the step
    # assembles the marker inventory + union registry from the overlay
    # payload and the source_hashes provenance, computes a projection-card
    # VLM output, builds the card envelope, and runs the v0 deterministic
    # gate (pass / needs_review / blocked). With the real-provider
    # selector OFF (default) the step uses the synthetic fixture — a
    # non-authoritative placeholder that NEVER auto-passes the gate, NO
    # real VLM call. The card is the common shot-observation SOT that
    # background_prompt (C4) and scene_detail (C5) consume so the BG plate
    # and the final shot t2i follow the same contract. Requires
    # ``base_location_dossier_enabled=True`` upstream.
    shot_projection_card_enabled: bool = False

    # ── W21B-wave-4 real projection-card VLM provider opt-in selector ──
    # False (default): the step's provider resolution returns None and the
    # synthetic fixture path is used. Zero external API calls. True: the
    # step resolves to ``litellm_projection_card_provider``. Flipping this
    # is what enables a real vision-LLM call per card; it must stay OFF
    # outside an explicitly approved canary. The helper's lazy
    # ``import litellm`` keeps the dependency out of the core import graph.
    shot_projection_card_real_provider_enabled: bool = False

    # Prompt-pack major version selector for the projection card. The step
    # records this in source_hashes so a pack bump invalidates cards.
    shot_projection_card_prompt_version: str = "1"

    # ── 카드의 공간 규칙을 이 샷에 해당하는 것만 싣기 (2026-08-26) ──
    # ★기본 **True** — 「골목 끝」 7샷 A/B 를 눈으로 보고 **B(켬)가 낫다**는
    #  판정을 받아 채택했다(2026-08-26). 되돌리려면 이 값을 False 로.
    # False: 종전과 **한 글자도 다르지 않다**. True: framing_scale 과
    # 인물 수가 **확정적으로 해당 없음**일 때만 그 규칙의 본문을 걷고
    # `applies_when` + 사유만 남긴다.
    #
    # 무엇을 고치려는 것인가: 인물이 한 명인 샷에 「두 인물이 상호작용할 때」
    # 규칙이 실리고, framing 이 medium 인 샷에 close 규칙이 실린다. 그 안에는
    # `absent — not rendered at all even if listed in visible_entities` 처럼
    # **적용되면 안 되는 지시**가 들어 있다.
    #
    # ★키는 남긴다 — 팩 `scene_detail/*/system.md` 가 세 규칙 이름을 직접
    #  부른다. 통째로 빼면 「카드가 준다는데 없다」는 더 나쁜 모순이 된다.
    # ★값을 **모르면 걷지 않는다.** RO-9(builder-static) 가 막던 실패는
    #  「입력이 비어 규칙이 조용히 사라지는 것」이므로 fail-safe 방향은
    #  그대로 둔다 — 확정됐을 때만 걷는다.
    card_spatial_rules_scoped_enabled: bool = True

    # ── W20B shot_aware_bg_render_plan opt-in selector ──
    # False (default): ShotAwareBgRenderPlanStep returns not_applicable.
    # True: per fp_id, the step assembles a planner input bundle
    # (dossier + geometry_readback + overlay + master_plan +
    # shot_staging cp), runs a structural shot-readiness gate (NO
    # readiness LLM classifier — code only checks shot_id exact-ID
    # presence), and emits a graph-walked plan validated against
    # W20 boundaries: DAG / same-fp-only / max_refs<=2 / two-refs
    # distinct-space exact-ID set cardinality / camera-cell membership
    # in W20A2 candidate sets / exactly-one anchor / non-empty
    # rationale / synthetic-readback never marked production_clear.
    # **The step does NOT call any external LLM by default** —
    # build_render_plan_for_fp uses llm_provider=None and returns a
    # structurally-empty plan + diagnostic. A real-LLM smoke is
    # deferred to a future wave behind explicit user/Codex approval.
    # Requires the three upstream opt-in selectors to also be True.
    shot_aware_bg_render_plan_enabled: bool = False

    # ── W20B real LLM planner provider opt-in selector ──
    # False (default): the step's llm_provider resolution returns None
    # and build_render_plan_for_fp emits a structurally-empty plan +
    # diagnostic. Zero external API calls in this branch — production
    # safe.
    # True: the step resolves to
    # ``litellm_shot_aware_bg_render_plan_provider`` (W20B helper)
    # which performs a single direct ``litellm.completion`` call
    # against the W20B prompt pack (system + user_template +
    # OpenAI-strict-sanitized json_schema). The helper's lazy
    # ``import litellm`` keeps the dependency out of the step's
    # import graph until the selector is actively flipped. Flipping
    # this flag without ``OPENAI_API_KEY`` set or without the prompt
    # pack present fails closed at the helper's preflight — never
    # silently issues network traffic. Requires
    # ``shot_aware_bg_render_plan_enabled=True`` upstream; otherwise
    # the step is not_applicable even when this selector is True.
    shot_aware_bg_render_plan_real_provider_enabled: bool = False

    # ── W21B-w4 #4(C) render-input substrate consumer opt-in selector ──
    # False (default): background_render's shot_aware plan queue derives a fresh
    # plate's reference images from the adapter ``materialize_decision`` (FP|parents
    # either-or) exactly as before — byte-identical existing path.
    # True: the fresh ``render_new_plate`` branch instead resolves its render-input
    # substrate via ``background_render_substrate.resolve_render_substrate`` — FP PNG
    # first, then the 3b reference-DAG ``ref_tree_parents`` (canonical rendered-plate
    # anchors) ordered space_continuity → style, with a missing parent gracefully
    # dropped (fallback_reason) rather than blocking. reuse-alias / fp-less paths are
    # untouched. Requires ``shot_aware_bg_render_plan_enabled=True`` upstream (the
    # plan checkpoint + 3a/3b fields must exist). PNG substrate only — plate prose
    # stays with the C4 projection-card boundary.
    bg_render_substrate_enabled: bool = False

    # ── W21B-w5 bg_space_partition step opt-in selector (D2, order 21.594) ──
    # False (default): BgSpacePartitionStep returns not_applicable — production
    # path byte-identical (no checkpoint, no consumer change). True: per fp_id the
    # step reads the base_location_dossier checkpoint, builds the deterministic
    # candidate edges (build_candidate_edges over the structural-unit signature +
    # IDF ubiquity + hub isolation), adjudicates the borderline edges with the
    # pass-2 LLM judge over the projection-card visible_items, and assembles the
    # space_partition_plan (plate groups / anchor / render_action / ref_tree). This
    # is the LLM SOT for cross-zone reuse; the wave-4 geometry/band PLATE_PARTITION
    # / REFERENCE_DAG mirrors are demoted to candidate/diagnostic (D1). Requires
    # ``base_location_dossier_enabled=True`` upstream and the shot_projection_card
    # checkpoint present (cards seed the edge judge). No image / DB / ImageAsset
    # write. SCHEMA/cp impact only when explicitly enabled.
    bg_space_partition_enabled: bool = False

    # ── W21B-w5 real edge-judge LLM provider opt-in selector ──
    # False (default): the step's provider resolution returns None and every
    # candidate edge is recorded as a ``skipped`` judgement (provider_disabled) —
    # no strong edge forms, so each bg keeps its own plate. Zero external API
    # calls. True: the step resolves to ``litellm_edge_judge_provider`` (one text
    # ``litellm.completion`` per judged edge, no VLM re-call — the projection cards
    # already carry the VLM-read visible_items). The helper's lazy ``import
    # litellm`` keeps the dependency out of the import graph; flipping this without
    # ``OPENAI_API_KEY`` fails closed at the helper preflight. Requires
    # ``bg_space_partition_enabled=True`` upstream.
    bg_space_partition_real_provider_enabled: bool = False

    # Edge-judge cost caps (Codex lock ⑤). Per-fp cap bounds the candidate edges
    # judged within one dwelling; the global cap bounds total LLM calls across the
    # episode. When a cap is reached the remaining edges are recorded as
    # ``skipped`` (cap reason) — never silently dropped, never auto-merged.
    bg_space_partition_edge_cap_per_fp: int = 40
    bg_space_partition_llm_call_cap: int = 120

    # ── W21B-w5 STEP5-B floor_plan_light_sidecar step opt-in selector ──
    # False (default): FloorPlanLightSidecarStep returns not_applicable —
    # production path byte-identical (no checkpoint, no consumer change). True:
    # per fp_id the step reads the floor_plan_prompt numbered_elements, builds
    # the LIGHT-FP LLM prompt bundle (render-role select + room-aware schematic
    # prompt), validates the structural skeleton, renders a CV-readable
    # PARTITION-DIAGRAM sidecar PNG, and persists it under the step checkpoint.
    # This is an I2I-reference sidecar ONLY — the DETAILED floor_plan_render PNG
    # stays the shot_projection_card / edge-judge substrate, so the W21B-w5
    # partition SOT is never disturbed (STEP5-B boundary lock). The background
    # render consumer (commit④) swaps the FP anchor PNG to the light sidecar when
    # present + valid, else falls back to the detailed FP. Requires
    # floor_plan_prompt_version ∈ {"6","7"}. SCHEMA/cp impact only when enabled.
    floor_plan_light_sidecar_enabled: bool = False

    # ── W21B-w5 STEP5-B sparse sidecar (2026-06-01) models + best-of-N ──
    # Design: gpt-5.5 frequency-aware sparse selection (8-10 essential numbered
    # markers, guided by each element's camera_use_count so high-frequency
    # TV/curtain are never dropped) → PURE text-to-image render of the LLM-written
    # room_schematic_prompt (no reference image — a reference leaks/duplicates
    # marker numbers; the v0 ref-edit produced "11" twice). Render model =
    # gpt-image-2 (user visual gate 2026-06-01: on the same prompt it honours
    # "exactly one circle per number" far better than Nano Banana 2, which
    # duplicated markers on repeated office furniture; the residual STRUCTURE
    # issues — over-segmented / interior-convention exteriors — are upstream
    # floor_plan_prompt skeleton problems shared by both image models, not a
    # render-model fault). best_of_n draws are rendered and the primary sidecar is
    # the first successful draw (all draws persisted for the visual gate). Read
    # only when floor_plan_light_sidecar_enabled (default OFF) → production path
    # unchanged.
    floor_plan_light_sidecar_select_model: str = "openai/gpt-6-astra"
    floor_plan_light_sidecar_render_model: str = "gpt-image-2.5-sunburst"
    floor_plan_light_sidecar_best_of_n: int = 2

    # ── W21B-w5 STEP5-B real light-FP LLM provider opt-in selector ──
    # DEPRECATED / UNUSED. Superseded by floor_plan_light_sidecar_enabled gating
    # the NB2-sparse path directly. Field kept (not removed) to avoid churn; safe
    # to delete in a follow-up cleanup.
    floor_plan_light_sidecar_real_provider_enabled: bool = False

    # ── W21B dwelling_zone_map (2026-06-08) — FP-image + VLM zone grouping ──
    # opt-in (default OFF) producer of a per-dwelling zone map that bypasses the
    # edge-judge (bg_space_partition), which cannot group same-dwelling bgs
    # framed from different camera angles. When enabled: distil base-set rooms
    # (gpt-5.5) → clean B&W 2D floor-plan prompt (gpt-5.5) → render a NEW clean
    # plan (gpt-image-2 text-to-image; the distorted floor_plan_render PNG is NOT
    # reused) → gpt-5.5 VISION maps each bg to its plan zone + grid focus → a
    # deterministic join produces the zone-map contract + a SEPARATE annotated
    # (numbered, render-forbidden) FP. Requires floor_plan_prompt_version ∈
    # {"6","7"}. SCHEMA/cp impact only when enabled (default path byte-identical).
    # Phase 1 produces + validates the contract only (no renderer wiring yet).
    dwelling_zone_map_enabled: bool = False
    # When the step is enabled but this is False, every dwelling gets the
    # non-authoritative synthetic fixture (no LLM/VLM/image call) — the plumbing
    # path. True = real FP generation + VLM mapping (the canary / production path).
    dwelling_zone_map_real_provider_enabled: bool = False
    dwelling_zone_map_text_model: str = "openai/gpt-6-astra"
    # ★#92 (2026-08-27): VLM 은 gemini + grok 만.
    dwelling_zone_map_vision_model: str = "gemini/gemini-3.1-pro-preview"
    dwelling_zone_map_render_model: str = "gpt-image-2.5-sunburst"

    # W21B-W7 (2026-06-12): visual_continuity_anchor step (order 21.66) —
    # B-run 피드백 버킷 C(zoom pose)/D(printed prop) 의 visual anchor manifest.
    # OFF (default) 시 not_applicable cp 만 — 기존 경로 byte-identical.
    # group_cap = per-episode LLM anchor 추출 콜 상한 (초과 seed 는
    # skipped(reason="cap") 진단 — silent drop 금지).
    visual_continuity_anchor_enabled: bool = False
    visual_continuity_anchor_group_cap: int = 8
    # D(printed_prop) 최소 예약 슬롯 — C 가 많은 에피소드에서 1차 핵심인 D 가
    # cap 에 밀리지 않게 (Codex W_A_STAGE_REVIEW 공정성 장치).
    visual_continuity_anchor_d_reserved_slots: int = 2

    # P8 (2026-06-20): immobilized_subject continuity anchor — 같은 씬에서 부동
    # (dead/unconscious/severely_injured) 인물이 여러 selected shot 에 걸칠 때
    # 공유 포즈/상태 계약을 1회 추출해 멤버 전샷 scene_detail 프롬프트에 주입.
    # visual_continuity_anchor_enabled 와 AND 게이트 — 둘 다 ON 이어야 작동.
    # cap 은 printed_prop cap 과 독립 (Codex 합의 ⑧). OFF (default) = no-op.
    immobilized_subject_continuity_enabled: bool = False
    immobilized_subject_continuity_cap: int = 4
    # P8 I-1 (2026-06-28): registered (bg-aware) immobilized pose guide. Inc1
    # 텍스트 계약의 prompt-only 천장(시신 자세/지지면 드리프트) 돌파용 시각 anchor.
    # ★ VCA step(21.66)엔 bg plate 가 없어(background_render=24.72) image phase
    # (scene_generation)에서 생성한다 — group environment plate 를 faint underlay 로
    # 깔고 그 위에 마네킹 pose 를 등록(gpt-image-2 edit). registered_pose_guide_service
    # 가 gate/cache/생성/diagnostic 담당. 4중 게이트(visual_continuity_anchor_enabled
    # AND immobilized_subject_continuity_enabled AND 본 flag) + bg plate bytes 실재.
    # 실패 시 white-bg fallback 없음 → 시각 가이드 생략(text 계약+dead ref 로 degrade).
    # default OFF = no-op (coordinator path byte-identical).
    immobilized_registered_pose_guide_enabled: bool = False
    immobilized_registered_pose_guide_model: str = "gpt-image-2.5-sunburst"
    # underlay 가공 variant — 'original'(전체 bg, support surface 전달 최강) 기본,
    # 과복사/실사화 시 'blur_gray' 실험.
    immobilized_registered_pose_guide_underlay: str = "original"
    # A5 (2026-07-02): immobilized 그룹 prev 완성프레임 chaining — 그룹의 후속 멤버
    # 샷(insert 등)에 선행 environment 멤버의 현재-run 완성 프레임을 연속성 ref 로
    # 부착 (star-to-environment, Codex 합의). anchor 프레임 부재 시 stale fallback
    # 없이 no-op + 진단(previous_frame_required_missing 계열). VCA 두 flag 와 AND
    # 게이트(manifest 로더가 이미 게이트). default OFF = no-op (byte-identical).
    immobilized_prev_frame_chain_enabled: bool = False
    # B (2026-07-02): outdoor 관련샷 prev 완성프레임 의무첨부 — outdoor(background_
    # classify is_indoor==false 구조 조인) + bg plate 無 + 연속성/의존 신호(dep 또는
    # same-scene 선행샷) 샷에서 prev 프레임을 요구. VE 에 location 이 없어 location_
    # history 를 놓치는 dormancy 를 scene_director.primary_location 구조 fallback 으로
    # 보정(short_id→UUID 역매핑, 글자패턴 0). 미해결 = previous_frame_required_missing
    # 진단(WARNING+still metadata, hard fail 아님). default OFF = no-op.
    outdoor_prev_frame_required_enabled: bool = False
    # C2 2단계 (2026-07-02): 씬 스틸 단일 연속 프레임 VLM readback — 생성물이 패널
    # 그리드/콜라주/분할화면이면(S21sh10 2×2 실측 결함) generic correction 1줄을
    # 덧붙여 1회 재생성. 판정=VLM boolean schema(default-deny: medium/high 확신의
    # 위반만 재시도, 글자패턴 0), 재시도 후에도 위반이면 진단만(연쇄 재시도 금지).
    # scene LVM(mode 게이트, E2E 는 off)과 독립. default OFF = 호출 0.
    scene_single_frame_readback_enabled: bool = False

    # Wave5 (2026-06-30) — 실내 일반 샷 shared-model pose 가이드. same scene+bg_id
    # multi-shot 연속성 그룹에 default-deny judge + guide QC 2중 게이트 통과 시
    # bg plate underlay 기반 마네킹 가이드를 indoor_pose_guide ref 로 부착.
    # default OFF = no-op (coordinator path byte-identical). 실패/거부 = no-guide
    # degrade (★흰배경 fallback 없음 — bg plate underlay 필수).
    indoor_shared_pose_guide_enabled: bool = False
    # judge(VLM route) ON 일 때만 attach 평가. OFF 면 candidate diagnostic 만.
    indoor_shared_pose_guide_judge_enabled: bool = False
    indoor_shared_pose_guide_model: str = "gpt-image-2.5-sunburst"
    # judge = text structured (Gemini Pro), qc = guide PNG VLM 이미지리드
    # (real VLM, fail-closed). Codex 정렬: image model 과 분리 + v1 단일 모델 고정.
    indoor_shared_pose_guide_judge_model: str = "gemini-3.1-pro-preview"
    # ★#92 (2026-08-27): VLM 은 gemini + grok 만.
    indoor_shared_pose_guide_qc_model: str = "gemini/gemini-3.1-pro-preview"
    # 4b fix (2026-07-01, Codex 합의): 단일 복잡/구도 indoor 샷 broad lane.
    # candidate_groups 가 2+ 멤버만 그룹화 → 단일 복잡샷이 judge 에 도달 못하던 병목
    # 완화(사용자 "한샷이라도 구도/복잡이면 실내외 무조건 마네킹"). ON 이면
    # selected indoor + actual bg plate + figure>=1 단일샷도 1-멤버 후보로 judge 에
    # 올린다(최종 attach 는 judge admit + QC pass fail-closed). 상위
    # indoor_shared_pose_guide_enabled 가 OFF 면 이 flag 가 True 여도 no-op.
    # default OFF = candidate_groups byte-identical.
    indoor_single_shot_pose_lane_enabled: bool = False

    # W21B-W7 W-C1 (2026-06-12): zoom_continuity_anchor step (order 21.73) —
    # zoom(C) anchor 를 shot_dependency_t2i(21.71) 이후로 분리 (fresh-run
    # 1-pass proof, Codex C3 변형 승인). D flag 와 독립 게이트.
    zoom_continuity_anchor_enabled: bool = False
    zoom_continuity_anchor_group_cap: int = 8

    # W21B-W8 (2026-06-12): outdoor_site_layout step (order 21.76) — 야외
    # location 별 sparse site layout 좌표를 SOT 로 위치/깊이/상대크기 구절만
    # 재작문 (image-phase prompt override, custom>zoom>site>original).
    # layout 이미지는 review 갤러리 전용 — scene ref 부착 금지 (ablation 실측).
    outdoor_site_layout_enabled: bool = False
    outdoor_site_layout_group_cap: int = 8
    # W22 (2026-07-10): 야외 직행 합성 체인 master flag — 장소 캐논 자산
    # (outdoor_place_spec/outdoor_place_canon) + 샷 grounding
    # (outdoor_shot_grounding) + scene_image nb2 직행(중간 plate 0). ON 시
    # 야외 라인이 직행 체인으로 대체되고 기존 야외 스택(site_layout/
    # shot_aware 야외 분/aerial/야외 plate)은 우회. 실내 라인 불변. OFF(default)
    # 시 전 경로 기존과 byte-identical.
    # 설계: docs/w22-outdoor-canon-direct-compose-design-20260709/design.md
    outdoor_direct_compose_enabled: bool = False
    # W21B-W8 composition guide (2026-06-13 재배선): 야외 departing 샷 한정 —
    # 3-모델 파이프라인으로 step 에서 구도 가이드 1장을 생성, scene 생성에
    # composition-only ref(role=composition_guide) 로 부착. 위의 'top-down
    # layout 이미지 부착 금지'와 별개 채널 — 카메라 시점 가이드는 spike(마네킹
    # +Loomis) 육안으로 구도/포즈/머리방향 전달이 검증됨. default OFF.
    #   ① 요소포함 구도 브리프 = gemini-3.1-pro-preview(text)
    #   ② 마네킹+Loomis 스케치 = gpt-image-2(아래 _model)
    #   ③ 최종 i2i still = gemini_image_model(nb2, 불변) — 스케치를 ref 로 소비
    outdoor_composition_guide_enabled: bool = False
    # 스케치(②) 생성 모델 — 관절 마네킹+Loomis 머리로 포즈/머리방향을 명시
    # (still 은 gemini_image_model 유지).
    outdoor_composition_guide_model: str = "gpt-image-2.5-sunburst"
    # 구도 브리프(①) 텍스트 모델 — geometry 에서 인물 배향/요소 화면배치 도출.
    outdoor_composition_brief_model: str = "gemini-3.1-pro-preview"
    # 구도 가이드 적용성 의미 게이트 모델 — 기하 후보가 '열린 외부 departure'
    # 인지(창/문턱/실내 구조물 프레이밍·근접 대치 아님) 판정하는 작은 분류기.
    # 작은 semantic classifier 라 브리프와 같은 text 모델 재사용(필요 시 상향).
    outdoor_composition_judge_model: str = "gemini-3.1-pro-preview"
    # Phase II II-1 (2026-06-29, Codex APPROVED_WITH_NARROW_SCOPE) — anchor 샷의
    # 구도 가이드 producer 를 per-shot 독립 마네킹 스케치(②, 환경 구조물 발명 →
    # nb2 still 장소 정체성 덮어씀, §4e-B R1) 대신 **site-layout master 파생
    # layout-only 카메라뷰 control sketch** 로 교체한다. clean top-down set-map
    # (글자 0) + 좌표 산술 camera brief → gpt-image-2 edit. 가이드는 layout/depth/
    # 배치/구조물 외곽 envelope 만 고정(재질/양식/디자인 정의 X — 그건 chaining+bg
    # ref 채널). ON 시 old 마네킹 스케치 producer 미호출, 생성 실패 = no-guide +
    # diagnostic(old fallback 금지). 게이트(G1/G2)·부착 정책은 불변. default OFF.
    outdoor_composition_guide_shared_model_enabled: bool = False
    # 4a fix (2026-07-01, Codex 합의) — outdoor single-shot lane broad seed. ON 이면
    # detect_site_seeds 가 단일 figure 복잡/구도샷도 seed(2인+ 요구 완화) + VE 에
    # location 없어도 primary_location fallback 으로 후보화한다. 실제 guide 발동은
    # 기존 shared-model judge/QC fail-closed 가 판정(broad seed=judge 대상, 무조건
    # 생성 아님). OFF(default)=legacy byte-identical. shared_model_enabled 와 함께 ON 해야 효과.
    outdoor_single_shot_seed_lane_enabled: bool = False
    # Phase II shared-model v2 (2026-06-29 재배선, Codex 정렬) — shared_model ON 의
    # 내부 옵션. True(default)면 cross-shot continuity candidate 를 route judge(group
    # 단위, evidence-backed)로 한 번 더 거른 뒤(candidate AND judge) attach 한다. False
    # 면 judge 호출 없이 candidate-only diagnostic 만 남기고 attach 하지 않는다(비용 0
    # 프리뷰/진단용). shared_model OFF 면 이 값은 무의미(config_hash 에 접지 않음).
    outdoor_shared_model_guide_judge_enabled: bool = True
    # ── s40/s41 콘티·스틸 레시피 이식 (2026-07-13) — 전부 opt-in, default OFF ──
    # 마스터 게이트: ON("v1") 시 shot_ref_classify/shot_continuity/shot_conti_light
    # 스텝이 applicable 해지고 scene_image 경로가 still_recipe(다중 참조+텍스트
    # 안전망+공통 multiroll 파이프)로 분기. "off"=전 경로 기존과
    # byte-identical (신규 스텝 not-applicable, config_hash 무스탬프).
    #
    # ★2026-08-05 사용자 지시 — 기본값을 "v1" 로 승격한다(이하 5개 동반).
    # 발단: 백엔드가 7/11 부터 재기동 없이 떠 있어 그때의 env 를 들고 있었고,
    # 8/4 재기동이 설정을 현재 .env(7/30 수정, 이 계열 없음) 상태로 되돌리면서
    # 스텝 9개가 통째로 not_applicable 이 된 채 유료 E2E 가 완주했다.
    # .env 는 .gitignore 라 이력이 없어 "켜져 있었다"를 복원할 방법이 없었다.
    # 그래서 켬을 파일이 아니라 **코드 기본값**으로 옮긴다 — 재기동이 설정을
    # 되돌려도 꺼지지 않는다. 끄려면 .env 에서 명시적으로 내린다.
    still_recipe_mode: Literal["off", "v1"] = "v1"
    # ★2026-08-27: v1 에서 `t2i_review` 의 **씬 갈래**를 건너뛴다.
    #  그 갈래는 `scene_detail` 의 t2i 산문을 검수하는데 v1 은 그 산문을
    #  이미지에 안 쓴다(최종 프롬프트를 typed SOT 에서 다시 조립).
    #  `mutation=0` 이라 산출 소비자도 없다. 되돌리려면 False.
    t2i_review_skip_scene_on_v1: bool = True
    # 표적 씬 슬라이스 실행 (2026-07-23 슬라이스 E, Codex 설계 합의):
    # 쉼표 구분 씬 index 목록("1,10,14"). 설정 시 씬 이미지(스틸) 생성
    # 실행 루프만 표적 씬+의존 클로저(effective allowlist)로 제한 —
    # 전체 stills 는 컨텍스트 SOT 로 유지(월드 가이드·groupbg 지문 불변).
    # ""(default)=전체 생성 byte-identical. force 모드와 병용 금지
    # (fail-closed — 비표적 자산 무효화 차단).
    scene_image_target_scenes: str = ""
    # 운영자 선언 무콘티 예외 (2026-07-30): 콘티 스텝이 끝내 실패한 샷을
    # **운영자가 명시적으로** BGFIRST 정책 대상에서 빼고 기존(무콘티) 경로로
    # 굽게 한다. 쉼표 구분 샷 태그 목록("S1sh7,S41sh2"). ""(default)=예외 없음
    # =기존과 byte-identical. 조용한 하강이 아니라 **선언된 태그에만** 적용되고
    # 적용 시 WARNING 을 남긴다 — bgfirst 의 "legacy 무음 하강 금지" 계약은
    # 선언되지 않은 샷에 대해 그대로 유지된다.
    bgfirst_no_conti_exempt_tags: str = ""
    # 공통 생성 파이프 롤 수. 2026-08-13 사용자 확정(#102): a/b 2롤 —
    # 첫 롤=기준, 둘째 롤=구도 변주 절 동반(전 백엔드·전 경로 무조건).
    # 3롤 시절 실측(2026-08-12, 215샷)=3롤의 c 승률이 바닥이라 축소 근거.
    still_recipe_roll_count: int = 2
    # 결함 게이트: 선정본 Gemini 결함 검사(CRITIQUE) → 결함 시 수정 프롬프트 저작
    # → nb2 i2i(선정 원본 단독 참조) → 수정본이 최종(_sel). OFF 면 선정본이 최종.
    #
    # ★2026-08-29 사용자 지시로 **기본을 끈다** — "4번부터 6번까지를 한꺼번에
    #  disable/enable 가능하게 하고 default 는 disable".
    #
    #  이 하나가 **비평·수정본·재판정 셋의 master** 다. 새 스위치를 만들지
    #  않았다(Codex 설계 리뷰) — 이미 같은 일을 한다:
    #    · `still_recipe.py:1072-1074` OFF → `critique_fn=None`·`fix_gen_fn=None`
    #    · `multiroll_select.py:1649-1657` OFF → 초기 승자를 `_sel` 로 두고 즉시
    #      반환 → 비평·수정·재판정이 **모두 0콜**
    #  그리고 지문도 이미 양쪽에 접혀 있다(샷 `multiroll_select.py:732`,
    #  outer `image_steps.py:713-718`).
    #
    #  ★제약은 돈이 아니라 **생성 시간**이다(사용자 확정). 이 셋이 한 샷에서
    #  VLM 직렬 호출 7~9회를 차지한다(비평 3 + 재판정 4, 콜당 평균 118초).
    still_recipe_critique_enabled: bool = False
    # ── 수리 수단 (2026-08-29 사용자 지시) ──────────────────────────
    # "지적 받은 내용을 바로 이미지를 i2i로 수정하는게 아니고 프롬프트에
    #  지적 받은 내용 강화해서 다시 1장만 생성하는 형태로"
    #
    #   edit       : 종전 동작 — 선정 원본을 i2i 로 고친다
    #   regenerate : 선정 롤이 실제 쓴 브리프에 지적을 붙여 **한 장을 새로**
    #                그린다. 원본 산출은 참조로 안 준다
    #
    # ★기본은 **`regenerate`** — 사용자 확정(2026-08-29). Codex 는 「기본을
    #  edit 로 두고 켤 때만 고르라」고 했으나 사용자가 뒤집었다:
    #  "regenerate 을 default 로 codex 틀렸어."
    #
    # ★그래도 오늘 도는 그림은 안 바뀐다 — 이 값은
    #  `still_recipe_critique_enabled`(④~⑥ master) 가 켜져야 읽히고 그
    #  master 자체가 기본 OFF 다. master 를 켜는 순간부터 표준 갈래가
    #  재생성으로 간다.
    #
    # ★이번 판의 배선 범위는 **표준 갈래 하나**다(`still_recipe_service`
    #  :4875). confined(도면 기하가 권위)·bgfirst(배경판이 카메라 권위)·
    #  variants 는 아직 edit 로 남는다 — 원리상 불가능해서가 아니라 가장
    #  흔한 경로에서 결과를 먼저 보려는 것이다. ab_active 의 rc=1 두
    #  갈래는 **구조상 제외**다: conti/noconti 두 파이프가 outer 선택
    #  전에 각각 수리해 「샷당 1장」계약이 최대 2장이 된다.
    still_repair_method: Literal["edit", "regenerate"] = "regenerate"
    # #77-B (2026-08-09 합의): 완료(already_done) 샷도 방문 시점에 기존 단계
    # 지문 게이트로 다시 재고, 낡은 샷만 재생성한다(전부 일치 = 지출·영속 0).
    # prev 앵커가 낡은 채 동결된 샷(S95sh18 부류)과 "팩만 올리면 253장 재사용"
    # 이 이 스위치로 잡힌다. off = 예전 그대로 완전 skip(되돌림 레버).
    still_jit_verify_enabled: bool = True
    # JIT 재생성 폭주 제동(#77-B) — 완료 샷 재생성이 이 수에 닿으면 그 자리에서
    # 멈추고 사람 확인을 요구한다(256샷 에피소드의 25%). 완료 샷이 무더기로
    # 어긋난다는 것은 지문 체계가 통째로 움직였다는 뜻 — 확인 없이 과금으로
    # 옮기지 않는다. 원인이 의도된 것이면 이 값을 올리고 다시 resume.
    still_jit_regen_limit: int = 64
    # ★사람이 손으로 올린 대표를 파이프라인이 내리지 않는다 (2026-09-20).
    #  걷기가 새 이미지를 **한 장도 안 사도** 캐시된 산출을 다시 영속하며
    #  같은 still 의 대표를 전부 내렸다 — 실측으로 오전에 올린 8장이 저녁
    #  걷기에 덮였다. 끄면 종전 동작(새 산출이 언제나 대표가 된다).
    image_protect_manual_primary: bool = True
    # ★이긴 후보에 **실격 결함**이 달린 채 나가던 것을 막는 게이트
    #  (2026-09-20). 실측: 이 화 239샷 중 81샷이 그랬다.
    #
    #  계약 — ①허용 후보(실격이 빈 것) 집합을 먼저 만들고 ②그 안에서
    #  기존 순위로 고른다(**이미 산 후보**라 구매 0) ③하나도 없으면
    #  `unresolved` 로 남긴다.
    #
    #  ★이 레버가 지금 **하는 일의 범위**(2026-09-20 Codex 정정):
    #    · 선정을 허용 후보로 바꾸고 종착을 기록한다
    #    · 미해결 샷의 그림을 **다음 샷의 앵커로 안 쓴다**
    #    · **아직 안 하는 것** — cine 구매·자산 승격·완료 표시를 막는
    #      소비자 쪽 문. 그래서 지금 켜면 「43샷을 막는다」가 아니라
    #      「43샷을 unresolved 로 분류하고 prev 전파만 끊는다」다.
    #
    #  ★적용 범위는 **표준 갈래**다. bgfirst·confined·conti A/B 는
    #   `not_applicable` 로 기록하고 「검사 통과」로 표시하지 않는다.
    #  ★기본 OFF — 기존 화에 유료로 적용할 때 범위·상한·예상 호출을
    #   따로 계측해 승인 범위에 넣는다.
    still_winner_gate_enabled: bool = False
    # ── 게이트 **2단계**: 못 쓸 산출을 다시 산다 (2026-09-20) ──────
    #  1단계는 「못 쓴다」고 분류하고 멈추는 것까지다. 실측으로 이 화
    #  43샷은 **무료 재선택이 하나도 안 됐다**(둘 다 못 쓰는 것) — 그래서
    #  실제 복구는 여기서만 난다.
    #
    #  유료: **이미지 1 + 재판정 1**(cross-model 이면 VLM 2). 「유료는
    #  이미지뿐」이 아니다.
    #
    #  ★상한은 **같은 입력·정책의 canonical 샷당 누적 1회** — 재개까지
    #   합산한다. 발송 결과가 불명이면 자동으로 새 1회를 주지 않는다.
    #  ★`still_recipe_critique_enabled`(master) 와 **독립**이다 —
    #   사용자가 시간 때문에 끈 관문을 켜지 않는다.
    #  ★게이트가 꺼져 있으면 이 레버는 아무 뜻이 없다.
    still_gate_reroll_enabled: bool = False
    # 주행 단위 승인 상한 — 한 걷기에서 게이트 재롤을 최대 몇 샷에
    # 살 것인가. 0 = 무제한(권하지 않는다). 샷당 1회와 **다른 축**이다.
    still_gate_reroll_run_limit: int = 24
    # #77-A 승인 레버: 의도된 config 변경(팩 내용 해시 도입·팩 올림)을 한 스텝
    # 에 한해 명시 승인(쉼표 구분 step_id, 예: "scene_image_pipeline").
    # 승인된 스텝만 config_hash mismatch 를 비파괴 resume 재실행으로 통과 —
    # 걷고 나면 새 hash 로 CP 가 다시 서므로 사실상 1회용. 평소 "" 유지.
    step_config_drift_ack: str = ""
    # 배경 플레이트(background_render 노드 렌더)에 공통 multiroll 파이프 적용.
    # still_recipe_mode 와 독립 게이트 (플레이트만 선행 적용 가능).
    plate_multiroll_enabled: bool = False
    # E2E6 피드백 ⑤: 같은 location 에 플레이트가 2장 이상이면 스틸 조립 시
    # VLM(gemini-pro)이 샷 텍스트 근거로 서브공간 플레이트를 선택(불명확=
    # 현행 배정 유지). OFF(default)=기존 배정 그대로(byte-identical).
    still_plate_select_enabled: bool = False
    # E2E6 피드백 ⑧: 일반 콘티 사용 샷의 A(콘티 포함)/B(미포함) 풀 3롤×2
    # 생성 → outer VLM 블라인드·순서 뒤집기 2회 판정으로 우세본 채택
    # (불일치=동점=콘티 사용본). 비용 2배 — 첫 acceptance 전용.
    # OFF(default)=기존 단일 파이프 byte-identical.
    still_conti_ab_enabled: bool = False
    # 스틸 생성 개편 (2026-07-17 사용자 확정): 스틸 프롬프트를 Sol 이 시각
    # 접근 변형 2종으로 저작(동일 프롬프트 반복 롤 대체). A/B 샷=변형2×
    # 콘티유무2=4장 병렬 → 단일 VLM 4택1(블라인드·정역순 flip) → critique
    # → i2i 수정 1장 — 기존 2단(브랜치 3롤+outer)을 대체. 비A/B 샷=2롤
    # 변형 병렬. 롤 생성=ThreadPool 병렬(budget·capture ctx 명시 전파).
    # OFF(default)=기존 경로 byte-identical (config_hash 무스탬프).
    still_variants_enabled: bool = False
    # 2026-07-19 스틸 공간 계약 fix1 (E2E9 S19sh4 실측): 스틸 base 프롬프트에
    # shot_staging 구도·스케일 계약(CAMERA/FRAME 절 — camera_direction+
    # framing_scale+frame_spatial_contract+key_bg_elements) 주입. 무콘티
    # 브랜치(C/D)·prev 샷·배경 제거 변형의 구도 정보 0 → 원근·스케일 붕괴
    # (CCTV 거대화) 대응. lane 샷 제외(마커 스케치=배치 SOT).
    # OFF(default)=byte-identical.
    # ★2026-08-07 기본값 ON 승격 (사용자 합의). 실측: 이 절이 7월 실행
    # 0/237, 8월 실행 0/255 — 한 번도 실린 적이 없다. 회귀가 아니라 처음부터
    # 미투입이었다. prev 경로 샷이 전체의 49%(126샷)인데 그 경로는 콘티도
    # 안 타므로 공간 계약이 통째로 0 이 되고, 좁은 실내(자동차 캐빈)에서
    # 운전석·룸미러·좌석이 무너진 육안 결함이 거기서 나왔다.
    still_recipe_camera_frame_enabled: bool = True
    # 2026-07-21 E2E10 fix⑤: 스틸 base 프롬프트에 LIGHTING & MOOD 절
    # (shot_staging.lighting_mood 결정론 렌더)+재질 사실감 계약(팩 v8
    # 단일 스템) 주입. 붉은 원 계열 bgonly 샷 평면 낙서화 실측 대응 —
    # 전 샷 적용(bgonly·lane 포함, 조명은 배치 권위와 무충돌). bgfirst
    # Step1 재투영 배경 프롬프트에도 동일 절. OFF(default)=byte-identical.
    # ★2026-08-07 기본값 ON 승격 (사용자 합의). 실측 0/237·0/255 로 미투입.
    # 육안 결함 S79sh1 = "밝은 조명이 켜진 선착장"이 영화 촬영용 투광등
    # 소품으로 그려졌다 — 조명을 말할 자리가 프롬프트에 없으면 그 요구가
    # 배치 계약 쪽으로 새어 물체가 된다.
    still_recipe_lighting_enabled: bool = True
    # 2026-07-22 E2E11 fix④⑤: 연기·형상 계약 — naturalism(차렷 직립·
    # 무표정 렌즈 응시 금지, 예외=샷 텍스트 명시 제식·운동·진료·직시)+
    # drawn_mark(그려진 표식=스트로크 윤곽 — 채운 원반 오렌더 실측 대응,
    # bgonly 포함 전 샷). 팩 still_recipe v10 스템. OFF(default)=
    # byte-identical.
    # ★2026-08-07 기본값 ON 승격 (사용자 합의). 실측 0/237·0/255 로 미투입.
    # 육안 결함 S88sh3 = "고정된 자세"가 차렷 마네킹 일렬로 그려졌다 —
    # naturalism 절이 정확히 그 실패(차렷 직립·무표정 렌즈 응시)를 막으려고
    # 만들어졌는데 한 번도 나가지 않았다.
    still_recipe_conduct_enabled: bool = True
    # ── QK 판정 체계 (2026-08-12 사용자 확정 "qwen 3.8 max main" + 같은 날
    # 밤 재결정 "gemini 를 보조로, QWEN 비중을 반대로") — 멀티롤 선정·수정
    # 재판정: **메인(취합·우선)=Qwen(DashScope 직결)** + **보조(관찰·교차)
    # =Gemini** (기존 G+Q 의 자리 반전). 처음 설계(Kimi 보조, 둘 다
    # OpenRouter)는 카나리아 실측(Kimi 평균 245s·Qwen 207s+비JSON 50%)으로
    # 보류 — OpenRouter 클라이언트·아래 슬롯 설정은 재도입 대비 보존.
    # OFF(default)=byte-identical. multiroll_gq_judge_enabled 와 동시 ON
    # 금지(fail-closed). ON 은 판정 지문(qk_select_policy)을 접는다.
    multiroll_qk_judge_enabled: bool = False
    # xAI native API 키 (2026-08-15) — 비교 결과 전환 기각(판정 동급,
    # i2i 는 native 가 6~8배 느림). OpenRouter 장애 시 failover 후보로만
    # 보존. 아직 어떤 호출 경로도 이 키를 읽지 않는다. 값은 .env 에서.
    xai_api_key: str = ""
    # OpenRouter API — 보류 중(Kimi 재도입·타 모델 실험 대비 보존)
    openrouter_api_key: str = ""
    openrouter_base_url: str = "https://openrouter.ai/api/v1"
    # (보류) OpenRouter 판정 모델 슬롯 — 현행 QK 는 이 슬롯을 읽지 않는다
    # (메인=qwen_vlm_model·보조=gemini_text_model 이 물리 SOT). Kimi
    # 재도입 시 지연 진단 선행.
    qk_judge_main_model: str = "qwen/qwen3.8-max"
    qk_judge_assist_model: str = "moonshotai/kimi-k3"
    # ── G+G46 판정 체계 (2026-08-13 사용자 확정 "Gemini + Grok 4.6 을
    # 50:50으로") — 멀티롤 선정·fix 재판정: **Gemini + grok-4.6(OpenRouter)
    # 동시 판정, 우선권 없음**(일치=채택 / 불일치=combined 합산 — 동등이라
    # 격차 문턱의 우선 채택 갈래가 없다). 수정 흐름: **양쪽 관찰 → Gemini
    # 취합 → fix i2i 는 Grok**(grok_image_model, cine 변환과 동일 클라이언트)
    # → fix 재판정도 같은 동등 이중. 근거=23샷 파일럿(grok 만 S64sh4 좌석
    # 물리 적중·지연 ~80-140s·~$0.04/판정, artifact/grok46judge23).
    # OFF(default)=byte-identical. GQ/QK 와 동시 ON 금지(fail-closed).
    # ON 은 판정 지문(gg46_select_policy 외)을 접는다 — 재실행 결정과 함께.
    multiroll_gg46_judge_enabled: bool = False
    # G+G46 판정 슬롯의 물리 모델 (OpenRouter 모델 문자열) — 지문 기여.
    grok_judge_model: str = "x-ai/grok-4.6"
    # 2026-08-12 차렷/증명사진 대응: 캐릭터 identity 참조 역할 한정 절 —
    # 인물 시트(정면 응시 참조)의 포즈·시선·구도 전이를 역할 문장으로 차단
    # (참조=얼굴·헤어·체격·복장 동일성 전용, 포즈·시선 권위=CAMERA/동작
    # 서술). 팩 still_recipe v16 단일 스템. 캐릭터 참조 실첨부 샷에만 절이
    # 나간다. OFF(default)=byte-identical — ON 은 인물 참조 샷 전부의 조립
    # 지문을 바꾸므로 전체 재실행과 함께 켠다 (완주 에피소드 지문 보호).
    still_identity_ref_role_enabled: bool = False
    # 2026-08-13 최종 스틸 생성 엔진 (#103, 사용자 확정 "grok 2 로 가자"):
    # "nb2"(기본, Gemini flash image — byte-identical) | "grok2"(OpenRouter
    # 경유 xAI Grok Imagine 2.0). grok2 는 조립도 바꾼다 — 컴팩트 지도 절
    # (still_recipe v17, xAI 8,000바이트 상한 대응)+시네마틱 마감 절+표준
    # 멀티롤 ab 변주(첫 롤=기준·나머지=구도 변주). config_hash 는 비기본
    # 값일 때만 스탬프(관례 동형) — 재실행 결정과 함께 켠다.
    still_image_backend: str = "nb2"
    # ★[2026-09-17 사용자 확정 「grok 이미지 최신으로 가자 ·
    #  grok-imagine-image-quality 을 디폴트로」] 2.0 → quality.
    #  실측(같은 cine 문안·같은 원본 S17sh18): chat 경로 11.3초 vs 18.3초,
    #  전용 images 경로 5.7초 vs 57.6초. 참조 붙은 i2i 도 정상.
    #  ★이 값은 cine 지문(cine_fingerprint v2)에 들어간다 — 판을 바꾸면
    #   완주 에피소드가 재방문 때 변환을 **다시 산다**. 재실행 결정과 함께.
    grok_image_model: str = "x-ai/grok-imagine-image-quality"
    # 2026-08-13 i2i 시네마틱 변환 스테이지 (#108, 사용자 확정 "nb2 생성 →
    # grok i2i 변환" 하이브리드): sel 확정 직후 grok 2.0 이 최종 스틸을
    # 영화 키프레임으로 재구성(장소·인물·순간·시대 불변, 프레이밍·조명만)
    # — 변환본이 scene ImageAsset primary 로 영속, 원본 _sel 은 prev 체인
    # 앵커로 보존. 문안=팩 still_recipe v19 `cine_transform` 단일 스템
    # SOT(화풍 교체=팩 버전 교체). 실패=원본 fallback+기록(재방문 재시도).
    # 참조 1장 i2i ≈10s/$0.03·215샷≈$6.5. UI 토글(설정 API)로도 전환.
    # OFF(default)=byte-identical — config_hash 는 ON 일 때만 스탬프(관례
    # 동형), 재실행 결정과 함께 켠다.
    still_cine_transform_enabled: bool = False
    # ── 마커 맵 엔진 (2026-08-25 사용자 지시) ────────────────────────
    # 「코드 상수를 설정으로, 문제가 생기면 후보 모델로」.
    # 대상은 **마커를 그리는 두 자리**뿐이다 — 장소 마커 맵
    # (outdoor_place_canon) 과 콘티 lane 마커 맵(shot_conti_light).
    # ★왜 이 둘인가: 실패가 이 자리에 몰린다. 08-25 실측 S2sh1 —
    #  「카메라 시야각 부채꼴이 피사체 방향(남동)이 아닌 반대(북서)로
    #  열려 있다」로 3회 소진, 스텝이 partial 로 멈췄다. 같은 결함이
    #  메모리에도 있다("geometry 는 정확한데 모델이 쐐기를 반대로 그린다").
    # ★같은 모델로 되풀이하는 것은 값이 없다 — 3회 다 같은 방향으로
    #  틀렸으니 우연이 아니라 계통적 편향이다. 그래서 **모델을 바꿔** 본다.
    # ★기본이 nb2 다 (2026-08-25 사용자 판정: "공간이나 방향은 nb2 가 훨씬
    #  잘하는 것 확인했어"). 종전 기본 `gpt-image-2` 는 같은 도해에서 두
    #  주행 6회 연속 시야각 부채꼴을 반대로 그렸다.
    marker_map_image_model: str = "nb2"
    # 검사 소진 시 넘어갈 후보 — **기본은 비어 있다**(2026-08-25 사용자
    # 결정: "그냥 nb2 만"). 모델을 갈아 봐야 실측상 나아지지 않았고
    # (nb2 1/2 가 최고, 나머지는 각자 다른 방식으로 틀렸다), 셋 다 쓰면
    # 한 샷에 아홉 장을 사게 된다.
    # ★대신 nb2 로 3번 그린 뒤 **그 셋 중 하나를 VLM 이 고른다** —
    #  통과가 없어도 가장 잘 지킨 판으로 계속 간다(멈추지 않는다).
    # ★값을 넣으면 그 순서로 물러난다. 슬러그는 OpenRouter
    #  `/api/v1/models` 응답에 **안 실리므로** 그 목록만 보고 "없다"고
    #  판단하면 안 된다(출처: openrouter.ai/collections/image-models).
    marker_map_fallback_models: str = ""
    # 2026-08-19 사용자 결정 — 같은 원본에서 검열 거부가 이 횟수만큼
    # 쌓이면 그 변환을 포기로 기록하고 **변환 안 한 원본**을 최종본으로
    # 확정한다. 실측: S49sh10·S73sh6 의 변환이 나흘간 일곱 번 전부
    # `imagine:content-moderated` 로 막혔다. 막는 것은 지시문이 아니라
    # 그림 내용이라(변환 지시문은 시나리오와 무관한 범용 한 단락) 다시
    # 보낼수록 같은 자리에서 막히고, ★거부당해도 요금이 나간다. 게다가
    # 변환 실패가 남으면 스텝이 안 닫혀 재개가 되풀이되고 그때마다 앞
    # 단계가 다시 돌아 연쇄 재생성이 났다. 0 이하 = 포기하지 않음(종전).
    # 지문 스탬프 없음(의도): 거부된 변환에서만 발화하는 회복 경로라
    # 전량 무효화가 부당하고, 발화 흔적은 records 의 cine 기록에 남는다.
    still_cine_moderation_give_up_after: int = 2
    # 2026-08-25 — 변환 산출을 재는 관문. 앞 단계는 판정을 겹겹이 거치는데
    # 최종 i2i 만 무검증으로 primary 가 됐다. 완주 판 7쌍 실측: 장소·인물은
    # 7/7 유지인데 광원이 7/7 바뀌었고(꺼져 있던 손전등이 켜진 샷 둘) 없던
    # 전경 물건이 4/7 에서 생겼다. 변환 문안을 고쳐도(팩 v23) 16칸 중 13칸에서
    # 광원이 여전히 바뀌어 **문안으로는 안 잡힌다**는 것이 실측이다.
    # 기각 = 원본이 최종본(확정된 결말, 스텝 닫힘). 검열 포기와 같은 모양이다
    # — 실패로 세면 스텝이 영영 안 닫혀 연쇄 재생성이 난다(08-19 실측).
    # OFF(default)=byte-identical. 지문 스탬프 없음(의도): 관문은 이미 산
    # 그림을 쓸지 말지만 정하고 그림 자체를 바꾸지 않는다 — 켰다고 전량
    # 무효화하면 안 되고, 발화 흔적은 records 의 cine 기록에 남는다.
    still_cine_verify_enabled: bool = False
    # 좌우를 바꿔 몇 번 묻는가. 2 = 두 순서가 **둘 다** 바뀌었다고 할 때만
    # 기각(위치 편향 방어). 1 로 내리면 값은 반이지만 실측에서 약한 축의
    # 좌우 불일치가 18% 였다 — 그 흔들림이 그대로 기각이 된다.
    still_cine_verify_rounds: int = 2
    # 2026-08-25 사용자 지시 "grok, reve 등으로 교환 가능하게" — 최종 i2i
    # 변환의 provider. "grok"(기본, OpenRouter 경유 xAI · byte-identical) |
    # "reve"(fal.ai queue 경유 Reve 2.1). 호출부는 client 만 갈아끼운다 —
    # `generate_image(prompt, labeled_references=...)` 슬롯이 같은 모양이다.
    # ★값: Grok 약 $0.03/장 · Reve $0.25/장 = **8.3배**. 81샷 판이면 변환만
    #  $20 대 $2.4 다. 기본값을 옮기는 것은 canary 실측 뒤에 한다.
    # ★provider·endpoint 는 변환 지문(cine_fingerprint v2)에 들어간다 —
    #  같은 문안·같은 원본이라도 **다른 제공자가 만든 그림은 다른 산출**이다.
    #  기존 완주본은 dual-read 로 재구매를 막는다(cine_transform 참조).
    still_cine_provider: str = "grok"
    #: 검열로 거절된 변환을 **다른 제공자로 한 번 더** 태운다.
    #:
    #: ★[2026-09-09] 파청(한말 의병 실화) 실측: MAI 변환 194건 중 34건이
    #:  실패했고 그중 24건이 **검열**이었다(ViolenceScore 14 · imagegen
    #:  safety 6 · mainline 2 · DallEBlockList 2). 전투·부상 묘사가 많은
    #:  작품에서 한 제공자의 기준에 통째로 막히는 것이다.
    #:  사용자 지시: 「검열에 걸린 부분은 재시도해서 안 되면 grok 으로」
#:  → 「검열 문제는 grok → seedream 으로 처리하자」(2026-09-09).
#:  실측: MAI 검열 34건 중 grok 이 32건을 살렸고, 남은 2건(유혈·시신
#:  묘사)은 grok·gpt-image-2 도 거절했는데 **Seedream 이 둘 다 해냈다**.
    #:
    #: ★빈 문자열이면 안 넘긴다(종전 동작). 주 제공자와 같으면 무의미하니
    #:  그때도 안 넘긴다 — 같은 기준에 또 막힐 뿐이다.
    #: ★[2026-09-17] 주 제공자가 grok 으로 바뀌면서 사슬에 **mai 를 넣는다**.
    #:  조립부는 주 제공자와 같은 이름을 건너뛰므로(`_cine_moderation_fallbacks`),
    #:  이 한 줄이 주가 grok 이든 mai 든 양쪽에서 맞게 돈다 —
    #:  주=grok → mai·seedream / 주=mai → grok·seedream.
    still_cine_moderation_fallback_providers: str = "grok,mai,seedream"
    #: ★[2026-09-18] xAI **직접** 편집 경로(`/v1/images/edits`)용 모델.
    #:  OpenRouter 의 grok 이미지 슬러그는 이날 14:18 부터 404 가 됐고(실측),
    #:  xAI 직접 편집만 원본을 유지하며 돈다. 문서상 최신은
    #:  `grok-imagine-image-2.0` 인데 이 계정은 404(접근 없음)라 쓸 수 있는
    #:  것은 `grok-imagine-image` 하나다 — 접근이 열리면 이 값만 바꾼다.
    xai_image_model: str = "grok-imagine-image"
    #: 편집 품질 — 사용자 지시(2026-09-18): low 말고 높여서. 네 값 모두 200.
    xai_image_quality: str = "high"
    # 최종 i2i — MAI (OpenRouter, Grok 과 같은 운반층). 2026-09-04 공개.
    # ★물리 모델 문자열이다. alias 를 넣으면 판 교체가 지문을 못 뚫는다.
    mai_image_model: str = "microsoft/mai-image-2.6"
    #: Seedream 5.0 Pro — OpenRouter **전용 이미지 엔드포인트**로 간다
    #:  (`/api/v1/images`). chat/completions 로 보내면 500 이다.
    seedream_image_model: str = "bytedance-seed/seedream-5-0-pro"
    # Reve endpoint — 참조 1장 편집. remix(여러 장)는 아직 배선하지 않았다
    # (설계 ⑩-4: 세 기하 권위가 동시에 서는 위험이 미측정).
    #
    # ★이 값은 fal 공식 예제의 model id 그대로다:
    #     fal_client.submit("reve/2.1/edit", arguments={...})
    #  fal 이 직접 호스팅하는 모델만 `fal-ai/` 접두를 쓰고, 제휴 모델은
    #  제공자 이름으로 시작한다. 2026-08-26 에 내가 검색 결과만 보고
    #  `fal-ai/reve/…` 로 바꿨다가 사용자에게 지적받았다 — **멀쩡한 값을
    #  틀린 것으로 바꾼 것**이다. 405 의 원인은 여기가 아니다.
    reve_image_model: str = "reve/2.1/edit"
    # 2026-08-25 사용자 지시 "카메라 앵글이나 기타 시네마틱 관련 프롬프트
    # 추가하는 것으로 해, 이전에는 하지말고" — 연출 재료를 **최종 i2i 에만**
    # 준다. 종전에는 최종 변환이 재료 0 에 자유 최대였다: 씬 원문도
    # camera_direction 도 lighting_mood 도 없이 「동기 있는 실제광을 쓰라」고
    # 했으니 무엇이 그 빛을 동기 짓는지 모르는 채로 지어냈다.
    # ON 이면
    #   ⓐ 앞 단계(roll base·체인·배경판)의 CAMERA **산문 한 줄**이 빠진다.
    #     구도(거리와 잘림·화면 배치·시각 관계)는 그대로 남는다 — 사용자
    #     판정이 「카메라 절 없는 쪽이 훨씬 좋다, 그런데 구도가 안 좋다」였고,
    #     그 구도를 잡던 것이 같은 절의 다른 줄이기 때문이다.
    #   ⓑ 최종 i2i 문안에 CAMERA·LIGHTING & MOOD 재료 절이 붙는다(팩 v24).
    # ★조명 절은 앞 단계에서 **빼지 않는다**: 빼면 E2E10 이 실측한 평면
    #  낙서화(S12sh18·S18sh10·S25sh5)가 되살아난다. 최종 i2i 는 같은 재료를
    #  받아 빛이 **어떻게 행동하는가**만 손대고, 어떤 빛이 있는지는 소스가
    #  이긴다(광원의 존재·종류·색은 세계 사실이라 연출 재량이 아니다).
    # OFF(default)=byte-identical — 팩 selector 도 v23 에 머문다.
    still_cine_stage_direction_enabled: bool = False
    # 2026-08-16 SAFETY 다단 후퇴 사다리 (사용자 지시 "거부 시 grok 전환,
    # 그래도 안 되면 연화 진행"): 스틸 롤/fix 생성이 moderation 거부되면
    # 원 모델 재시도 → 교차 백엔드(Gemini↔Grok) → 연화 프롬프트 → 교차+
    # 연화 순으로 후퇴한다. 실측 근거(08-16 01:4x S21sh2): 거부 3건 전부
    # 같은 입력이 재시도에 통과한 확률적 산출 검열 — 그래서 첫 단계가
    # 즉시 전환이 아니라 원 모델 재시도다. OFF(default)=기존 2회(원문→
    # 연화) byte-identical. ★config_hash 스탬프 없음(의도): 후퇴는
    # 거부된 샷에서만 발화하는 회복 경로라 전량 지문 무효화가 부당하고,
    # 발화 흔적은 llm_call_log 의 safety_ladder 메타·모델명으로 남는다.
    still_safety_fallback_enabled: bool = False
    # 2026-08-19 참조 선별 (사용자 지시 — 수정본이 원본 자세를 망가뜨린다):
    # i2i 수정 호출은 지금 critique 가 본 참조를 **전부** 동봉한다. 붙는 것
    # 다수가 인물 정본이고 그 라벨은 얼굴·머리·체형을 맞추라고 하므로,
    # 시계 바늘 하나 고치라는 지적에 "사람을 다시 그려라"가 함께 나간다.
    # 실측: 수정본 139장면 중 82장면(59%)이 개악으로 퇴짜, 퇴짜/채택을
    # 가른 것은 지시문 길이도 항목 수도 아니고 **참조 장수**뿐(중앙 4 대 3).
    # ON 이면 지적을 만드는 단계가 `needs_ref_indices` 를 구조 필드로 함께
    # 내놓고(글자 대조 아님), 요구된 참조만 붙는다. 아무도 요구하지 않으면
    # 편집 대상 원본 한 장만 간다. 없는 것을 새로 넣으라는 지적이면 그
    # 절이 지시문에 낀다(팩 multiroll_judge v13 추가 스템).
    # ★2026-08-20 정정: 그때 **참조가 함께 붙는 것은 코드 보장이 아니다** —
    # 붙일지는 같은 지적의 `needs_ref_indices` 에 모델이 그 번호를 적었는지로
    # 정해진다. 이름과 참조를 코드로 잇는 유일한 수단이 글자 대조라 금지되고,
    # 정본 없는 일반 소품이면 무조건 동봉이 오히려 틀리기 때문이다. 빈 채로
    # 나가는 빈도는 기록(fix_ref_gate.missing_without_ref)으로 재고 판단한다.
    # 시험=backend/fix_ref_gate_pilot.py · artifact/20260819_fix_ref_gate
    # (6장면 참조 23→9장, 생성 12장 전건 성공). OFF(default)=
    # byte-identical — ON 은 결함 검사 스키마와 수정 입력이 바뀌므로
    # config_hash 에 ON 일 때만 스탬프(관례 동형).
    still_fix_ref_gate_enabled: bool = False
    # Opik 기록 체계화 v2 (2026-08-23) — 계층(주행>작업 단위>호출)·축 태그·
    # 신원 metadata. 설계=docs/superpowers/specs/2026-08-23-opik-trace-
    # taxonomy-design.md
    # ★기본 OFF. 꺼져 있으면 Opik payload 가 지금과 바이트 동일하다.
    opik_trace_v2_enabled: bool = False
    # 2026-08-14 시대 인지 사전 조사 (사용자 확정 "무조건"): 지폐·브랜드
    # 가전·차량·시대 실내처럼 생성 지식만으로는 형태가 틀리고 시청자가
    # 알아보는 대상을, 생성 전에 원어 검색→VLM 선택→참조 동봉으로
    # 그라운딩한다(서울역 재교체 구조의 범용 편입). 판별·질의 저작=flash
    # 1콜, 선택=GPT LVM. 층위: 엔티티(소품·장소) 참조·groupbg 플레이트
    # (+샷 레벨은 후속 배선). OFF(default)=byte-identical — ON 은 판별
    # 정책·팩이 지문에 접혀 전량 재생성과 함께 켠다.
    era_research_enabled: bool = False
    # 2026-08-14 #119② (카나리아 S3 백지 팻말 실측): 장면이 부르는 실물
    # 표기의 **원어 문안을 저작해 공급** — 판별+저작 flash 1콜(대부분 빈
    # 목록), records 사이드카 캐시. 표기 정책 v20("허용+발명 금지")의 값
    # 공급부. OFF(default)=byte-identical — ON 은 정책·팩이 지문에 접힘.
    signage_author_enabled: bool = False
    # 2026-08-14 #119③ (카나리아 S64sh4 실측: 시트 0 인물의 롤 간 의상
    # 표변 + prev 앵커 인물 복제): ⓐ시트·배정 없는 인물의 의상을 아웃룩
    # 서술로 텍스트 잠금 ⓑprev 스틸 인물 계승을 앵커·현재 샷의 인물
    # 겹침으로 스코프(겹침 0 = 계승 금지 명문). OFF(default)=
    # byte-identical — ON 은 정책 문자열이 지문에 접힘.
    still_cast_wardrobe_lock_enabled: bool = False
    # 2026-07-22 E2E11 fix① (Codex BLOCKING-1 재설계): 배경 저작 system 에
    # 휴먼 스케일 앵커 규칙 부착 — 활성 selector(v7 shot-aware/v14 등)
    # 계약·Literal·preflight 무변경(선택형 팩 아님, 단일 스템 부착).
    # OFF(default)=byte-identical.
    background_scale_anchor_enabled: bool = False
    # 2026-07-20 이식 ①: 배경 렌더 무인(NO PEOPLE) 계약 — 렌더 seam
    # (render_one_background)에서 계약 절 강제 부착(단롤 gpt·plate_multiroll
    # 양 경로 상속). E2E9 L09B01 실측(플레이트에 인물 구워짐) 대응.
    # OFF(default)=byte-identical.
    background_no_people_enabled: bool = False
    # 이식 ① 무인 VLM 게이트(전자 ON 전제): 렌더 성공 후 Gemini 구조화
    # 무인 판정 → 위반 시 강화 절 1회 재렌더 → 재판정. 최종 위반=fail-open
    # +people_detected 감사. 플레이트당 판정 1~2콜 비용 — opt-in.
    background_no_people_gate_enabled: bool = False
    # 2026-07-20 이식 ②③ (BGFIRST2): 콘티 사용 샷의 스틸을 2단 체인으로
    # 교체 — GPT 원근 가이드 콘티(콘티 팩 v2: 샷별 place+CAMERA/FRAME+
    # PERSPECTIVE&SCALE GUIDES, 참조=플레이트+캐릭터) → Step1 gpt-image-2
    # 재투영 인물 0 빈 배경(nb2 는 플레이트 프레이밍 고수 실측) → Step2
    # nb2 인물 삽입. 롤 구조=콘티 체인 1장 vs 무콘티 1장 → VLM 2택1
    # (기존 4택1 대체). 비콘티 샷(prev/bgonly/lane/complex)=기존 유지.
    # ★2026-08-05 기본값 ON 승격 (사유=still_recipe_mode 주석 참조).
    still_bgfirst_enabled: bool = True
    # 2026-07-21 E2E10 fix③④ (사용자 확정 "콘티=무조건 배경 우선 전면화"):
    # BGFIRST 를 콘티 실재 전 샷으로 확장 — ①no_plate 샷도 콘티 저작
    # (콘티 팩 v3, 플레이트 무참조)→share_plan 그룹 단위 배경(groupbg)
    # 1회 생성·재사용→동일 2단 체인 ②complex/seed-bg 콘티 샷도 체인
    # 2택1 편입(4택1 대체, Step1 에 STRUCTURE LOOK 3참조). prev/bgonly/
    # lane 샷은 기존 유지. still_bgfirst_enabled ON 전제(서비스 fail-
    # closed). ★2026-08-05 기본값 ON 승격 (사유=still_recipe_mode 주석 참조).
    still_bgfirst_full_enabled: bool = True
    # 2026-07-25 사용자 확정 (야외 케이스1 스펙 A→F): lane(마커 스케치
    # 콘티) 샷과 prev 지휘 샷도 배경 재투영 2단 체인에 편입 — "배경 →
    # 배경 입힌 콘티 → 엔티티 입힘"을 이 샷들에도 적용. 실측 근거:
    # lane 샷은 배경 권위 자체가 없어 같은 장소 일반 샷과 다른 장소로
    # 그려졌고(S15sh1 vs S15sh5), prev 지휘 샷은 구도가 다른 직전 스틸을
    # 재투영 없이 참조만 해 장소가 재현되지 않았다. still_bgfirst_full
    # _enabled ON 전제(서비스 fail-closed).
    # ★2026-08-05 기본값 ON 승격 — 선택이 아니라 **코드가 요구**한다.
    # lane sketch 팩 v14 는 콘티를 마네킹으로 그리므로, 그 마네킹이 최종
    # 그림에 새어나가지 않으려면 배경·엔티티 교체 체인이 반드시 짝이어야
    # 한다. shot_conti_light 이 셋(bgfirst/bgfirst_full/lane_prev) 중 하나라도
    # 꺼져 있으면 fail-closed 로 죽는다(2026-08-05 실측: 6개만 켜고 돌렸다가
    # 이 지점에서 중단). 사유는 still_recipe_mode 주석 참조.
    still_lane_prev_bgfirst_enabled: bool = True
    # 2026-07-21 E2E10 fix②: critique→i2i 수정본 무판정 확정을 재판정으로
    # 교체 — [선정 원본 vs 수정본] 2후보 블라인드(정순+역순 flip) VLM 판정,
    # 승자=최종 _sel (수정이 더 나빠지는 실측 다수 대응, 동점=수정본).
    # 적용=공통 생성 파이프의 스틸 전 브랜치+배경 플레이트(plate_multiroll).
    # seed 멀티롤은 seed 전용 변형 판정 팩이 2후보 계약 미정의라 제외.
    #
    # ★2026-08-06 기본값 ON 승격 — 이 플래그가 꺼진 채 돌아간 결과를 실측했다.
    # C 실행은 253장 중 223장(88%)을 i2i 로 고친 뒤 **무판정 확정**했고,
    # 그 223쌍을 나중에 오프라인으로 재판정하니 **88장(39.5%)이 개악**이었다
    # (그중 45장은 원본이 5점 이상 우세). 반대로 A 실행은 이 플래그가 켜져
    # 있어 13건 중 3건(23%)이 수정본 기각·원본 복귀로 걸러졌다.
    # 8/5 플래그 승격 목록에서 이것만 빠져 있었다.
    multiroll_fix_rejudge_enabled: bool = True
    # 2026-07-22 E2E11 fix③ (사용자 확정): GPT 구도 전담 critique 합류 —
    # 선정본을 GPT LVM 이 구도·배치·스케일·시선축만 검사(그 외 봉인),
    # Gemini critique 수정사항과 **합산한 하나의 수정 프롬프트**로 i2i.
    # 적용=스틸 전 브랜치+플레이트. OFF(default)=byte-identical.
    multiroll_gpt_composition_enabled: bool = False
    # 2026-07-22 E2E11 ② (L13B01 계단 반전·L04B01/02 계단 누락 실측):
    # shot_aware 플레이트 prefix 에 ⑴STRUCTURE FACTS(floor_plan_prompt
    # numbered_elements 원문 나열 — 뷰 안 구조 요소 존재·연결·승강 방향
    # lock, 미러/반전 금지) ⑵VIEW AUTHORITY(카메라 기하와 저작 산문의
    # 시점 모순 시 기하 우선 — 두 시점을 융합한 불가능 위상 차단) 주입.
    # 생성·plate_multiroll 판정·critique 가 prompt 를 공유하므로 검출
    # 축도 함께 확보. OFF(default)=byte-identical.
    plate_structure_facts_enabled: bool = False
    # 2026-07-19 재설계 C: lane 판별 팩 selector — "2"=lane "none"(일반
    # 파이프) 신설 팩(맵=배치가 샷의 요점인 개활지만, 애매하면 none).
    # default "1"=기존 프로젝트 byte-identical, E2E9 에서 "2" ON.
    # 2026-07-25 (사용자 지적② — 옥탑이 map_marker 로 샘): 기본값을 "4"
    # 로 올린다. v4=장소 단위 복잡 구조물 선판정+결정론 강등. 기본값이
    # 낮으면 게이트가 저작 자체에 걸리지 않아 수정이 무효였다(Codex
    # HIGH-4 실측: selector 1 이면 site schema/강등 미실행).
    outdoor_lane_plan_prompt_version: str = "4"
    # 2026-07-19 재설계 A: 야외 플레이트의 공간·룩 참조=장소 seed(정면
    # 실사 앵커, aerial 위성 배치도 전면 대체 — 사용자 확정). ON 시
    # 야외 loc 렌더에 그룹 seed 를 1순위 첨부(aerial 채널 재사용),
    # seed 없는 그룹=기존 fail-safe. OFF(default)=byte-identical.
    outdoor_seed_plate_reference_enabled: bool = False
    # 2026-07-19 재설계 B-1: seed 생성 대상=야외 전 그룹(lane 무관) —
    # '비슷한 배경 공유 장소=seed 기반 배경 하나' 지시의 전제.
    # OFF(default)=기존 structure_plate 그룹만(byte-identical).
    outdoor_seed_all_groups_enabled: bool = False
    # 2026-07-19 재설계 B-2: 에피소드 전체 배경 공유·참조 계획(LLM 지휘,
    # 계획=prev/배경 결정의 상위 권위).
    # ★2026-08-05 기본값 ON 승격 (사유=still_recipe_mode 주석 참조).
    background_share_plan_enabled: bool = True
    # seed 품질 2R (2026-07-16 사용자 지시): outdoor_structure_seed 의 seed
    # 생성을 '동일 프롬프트 N롤'에서 'LLM(Sol) N종 프롬프트 변형 저작
    # (v1=시나리오 원어 근거)→각 1롤→VLM 블라인드 conformance 판정 선정'
    # 으로 교체. 규모·개구부·장소 서술=전부 LLM 저작(수치 하드코딩 0).
    # OFF(default)=기존 경로 byte-identical (config_hash 무스탬프).
    structure_seed_variants_enabled: bool = False
    # (DEPRECATED·DORMANT — 2026-07-16 복잡 구조물=A/B 재설계) 레인2
    # frame_mode 판정: structure_plate 스케치 경로 소멸로 소비처 0 —
    # applicability.outdoor_frame_mode_on() 이 항상 False 를 반환해 이
    # flag 는 어떤 값이어도 무효(E2E7 ON 목록에서 제외). flag·스텝·모듈
    # 은 삭제 금지 원칙으로 보존만.
    outdoor_frame_mode_enabled: bool = False
    # 야외 복잡 구도 샷(LLM 판별)을 사이트 맵 grounding(카메라·마커 배치)→맵 기반
    # 플레이트→경량 콘티로 변환하는 분기. site map 부재 시 일반 콘티 경로 degrade.
    # 설계 v2(2026-07-14)로 대체 예정 — outdoor_lane_plan 계열이 신규 SOT (dormant 유지).
    outdoor_map_conti_enabled: bool = False
    # 야외 3레인 재설계 Stage A (2026-07-14, 설계 v2): 야외 장소를 place_segment
    # 로 분할하고 선택 샷을 레인(map_marker/structure_plate)에 바인딩하는
    # outdoor_lane_plan 스텝 게이트. 이미지 생성 없음(분류 전용).
    # ★2026-08-05 기본값 ON 승격 (사유=still_recipe_mode 주석 참조).
    outdoor_lane_plan_enabled: bool = True
    # 야외 3레인 재설계 Stage D (2026-07-15): lane plan 을 실제 이미지 파이프에
    # 배선 — outdoor_structure_seed 스텝(레인2 seed+SITE PLAN), shot_conti_light
    # 의 lane 분기(마커 스케치 콘티), still_recipe lane 참조. outdoor_lane_plan_
    # enabled 선행 필수.
    # ★2026-08-05 기본값 ON 승격 (사유=still_recipe_mode 주석 참조).
    outdoor_lane_pipe_enabled: bool = True
    # ★중앙 참조 획득의 대상 병렬 폭 (2026-09-03 · Codex 계약: 기본 4 · 무제한 금지).
    #  429 가 심하면 3 으로. 상한의 정본은 ChunkJournal.reserve — 이 수는 동시성 폭일 뿐이다.
    grounding_acquire_workers: int = 4
    # ── 구조 파이프 정책 (2026-07-31 편입 설계 §5.1) ────────────────
    # 조합 유효성의 소유자는 app.core.structure_pipeline_policy 이다.
    # 여기서는 값만 받고 검증하지 않는다 — 검증을 두 군데 두면 갈린다.
    # 기본값 = 오늘의 경로(legacy · 전부 off).
    structure_seed_recipe: str = "legacy"
    place_mark_mode: str = "off"
    seed_critique_mode: str = "off"
    # W-G (2026-07-03) — 같은 building_groups 그룹에 실내·실외 loc 이 공존하면 그
    # 건물의 실내 floor plan 을 외부 산출물의 구조 참조로 첨부한다: ① outdoor_
    # site_layout Stage A aerial base 를 T2I→I2I(fp ref) 승격 ② background_render
    # 의 outdoor plate(direct-plate/shot_aware 양 lane)에 fp 를 추가 ref + lineage
    # (input_image_ids → 캔버스 fp→bg 엣지). 링크 조인은 building_fp_link 모듈
    # (구조 필드만 — 파일명/라벨 파싱 0). OFF(default) = 로더 미호출, 기존 경로
    # byte-identical.
    outdoor_building_fp_ref_enabled: bool = False
    # W-I (2026-07-03) — building group anchor 렌더 체이닝. 같은 building 그룹
    # (실내·실외 공존)의 outdoor bg 들이 도면(fp)만 공유하고 렌더를 상호 참조하지
    # 않아 plate 마다 건물 외관(재질/색/문·창/설치물)이 재발명되는 결함의 근본
    # 대응: 그룹의 첫 ok 렌더를 anchor 로 등록하고 이후 같은 그룹 outdoor bg
    # 렌더(direct-plate/shot_aware 양 lane)에 시각 정체성 ref 로 첨부(경로 dedup)
    # + BUILDING_ANCHOR_PLATE_GUIDANCE. outdoor 멤버십/그룹 판정은 W-G 링크 맵
    # (building_fp_link 구조 조인) 재사용 — fp "첨부" 여부는 W-G flag 가 별도
    # 결정. lineage 는 building_anchor_ref 구조 필드 → input_image_ids 병합
    # (캔버스 bg→bg 엣지). OFF(default) = 기존 경로 byte-identical.
    outdoor_building_anchor_ref_enabled: bool = False
    # W-K (2026-07-03) — 같은 장소 plate 무조건 렌더 체이닝. 같은 location 의
    # 렌더들이 상호참조 0 으로 제각각의 공간을 재발명하고, 같은 building 그룹의
    # 실내 렌더가 외부 렌더와 단절되는 결함의 근본 대응(W-I 의 same-place 확장):
    # ① lane 재배열 — fp lane 을 direct-plate lane 앞으로 + 양 lane 내 mixed
    #   그룹 outdoor 소속 우선 안정정렬(외부 establishing 이 기준이 되도록)
    # ② loc anchor — 모든 loc(실내 포함)의 첫 ok 렌더를 등록, 이후 같은 loc
    #   렌더에 SAME_PLACE_PLATE_GUIDANCE 와 함께 첨부
    # ③ 그룹 anchor 확장 — 등록은 outdoor ok 렌더만, 첨부는 그룹 전 멤버
    #   (실내 포함) + 실내↔실외 정합형 guidance 로 교체
    # ④ 추가 anchor cap 2(loc+group, 같은 png 면 1) + 경로 dedup
    # ⑤ lineage — same_loc_anchor_ref 구조 필드 → input_image_ids 병합.
    # 멤버십 판정은 building_fp_link.build_group_membership_by_loc(구조 조인 —
    # 파일명/라벨 파싱 0). OFF(default) = 기존 경로 byte-identical.
    same_place_render_chain_enabled: bool = False
    # W-L (2026-07-03, 7차 정정 반영) — 야외 기준 재편: 항공뷰 배치도(실내
    # floor plan 과 같은 도면 언어의 야외판, top-down site plan) 1장/loc =
    # 야외 위상 SOT. 같은 loc 에 샷 관점별 fp 가 여러 장으로 쪼개져(fp 분열)
    # plate 끼리 서로 다른 위상으로 갈라지는 결함의 근본 대응:
    # ① background_render 큐 시작 시 야외 loc(background_classify is_indoor==
    #   False 구조판정)마다 배치도 1장 T2I(마커 0, 건물 소속 상층 loc 은 그
    #   레벨의 평면으로 — generic 계약, mixed 그룹이면 indoor fp 를 I2I ref
    #   로 footprint 정합), loc당 1장 캐시
    #   (images/background_chain/aerial_<loc>.png, resume 재사용/force 재생성)
    # ② 야외 bg 렌더(fp lane+direct lane)는 평면도 ref 를 제외하고 aerial 을
    #   **1순위** ref 로 + AERIAL_SITE_GUIDANCE (이후 순서 = LLM 체인 plate →
    #   same_loc anchor → building anchor → 실내 fp 마지막; W-G/W-K 불변).
    #   aerial 실패/부재 시 해당 loc 은 기존 fp 경로 그대로(fail-safe).
    # ③ lineage — ImageAsset(asset_type='location_aerial') UPSERT + bg
    #   input_image_ids 1순위 aerial UUID + entry.aerial_ref 구조 필드.
    # 실내 loc 불변(실내 fp 1순위 정당). master_plan/fp 산출은 무수정 — fp 는
    # projection card/plan 용도로 잔존. producer=location_aerial 모듈(구조
    # 조인 — 파일명/라벨 파싱 0). OFF(default) = 기존 경로 byte-identical.
    outdoor_aerial_reference_enabled: bool = False
    # W-M (2026-07-03, 사용자 9차 피드백) — 야외 plate 참조 체계 단계화.
    # 야외 bg 가 실외 도면(aerial)+실외 렌더(anchor)+실내 도면(building fp)을
    # 동시 참조(실측 4장)해 할루시네이션을 키우고, 실내 bg 가 건물 외부
    # 렌더를 참조하는 결함의 근본 대응(사용자 계약: 실외·실내 도면 동시
    # 첨부 금지 + 건물 외부 이미지를 실내에 사용 금지):
    # ① 그룹 체인 lane — 같은 장소 그룹(aerial ok)의 야외 render_new_plate
    #   bg 를 기존 lane 에서 추출해 순차 체인 렌더. ref 구성: 첫 plate =
    #   그룹 통합 배치도(aerial) 1장만, 이후 = 이전 ok plate + 배치도(최대
    #   2장). 자기 fp/substrate prior/anchor/building fp 는 첨부하지 않는다
    #   (fp 정합은 배치도 생성 I2I 가 흡수).
    # ② plate 생성 순서 = LLM 결정(background_plate_order, gpt-mini alias) —
    #   순열+depends_on 제약 결정론 검증, 실패 시 구조 순서 fallback.
    #   depends_on_bg 상태변형은 그 base ok plate 를 prev 로 우선 선택.
    # ③ 실내 plate 보호 — W-K 그룹 anchor(외부 렌더) 첨부를 야외 멤버
    #   한정으로 좁힘. 실내↔실외 연속은 INTERIOR_SAME_BUILDING_TEXT_
    #   GUIDANCE(프롬프트 문구만, 이미지 ref 0).
    # ④ 잔존 야외 경로(aerial 실패/degrade)는 기존 fail-safe 그대로 — 단
    #   aerial 이 첨부되는 렌더에는 building fp 를 첨부하지 않는다(동시 금지).
    # 그룹 체인은 outdoor_aerial_reference_enabled(W-L) ON + aerial 산출 ok
    # 일 때만 발동. lineage = aerial UUID 1순위 + prev plate UUID(prior_bg
    # 채널) + entry.stage_chain 구조 필드. OFF(default) = 기존 경로
    # byte-identical.
    outdoor_plate_stage_chain_enabled: bool = False

    # ── W21B space_set_bg (2026-06-10) — frame/addon/life 3층 공간 세트 BG ──
    # 실험(geum_fp_frame_addon_exp 14회 육안 반복)으로 검증된 새 BG 경로:
    # 실내 = frame 2D→addon i2i 최종 2D FP→공간당 단일 번호 마킹→BG i2i (3D 미경유),
    # 옥외 = 공간당 단 한 장 T2I 기준 BG. default OFF — 기존 FP/BG 체인 영향 0.
    space_set_bg_enabled: bool = False
    # 등장 빈도 필터 — scene 등장 수가 이 미만(=1번 정도)인 배경 그룹은 기준 BG 생략
    # (사용자 2026-06-10: 여러 군데 등장하는 배경만 기준 BG 생성해 공동 사용)
    space_set_bg_min_scenes: int = 2
    space_set_bg_text_model: str = "openai/gpt-6-astra"
    # ★#92 (2026-08-27): VLM 은 gemini + grok 만.
    space_set_bg_vision_model: str = "gemini/gemini-3.1-pro-preview"
    space_set_bg_render_model: str = "gpt-image-2.5-sunburst"

    # ── problems.md #12 toggle ──
    episode_status_strict_projection: bool = True  # True 시 EpisodeProjectionService 가 step_run 검사 후 모든 active analysis step 이 completed/not_applicable 인 경우만 status='analyzed' 설정. False 시 옛 동작 (무조건 analyzed).

    # ── visual_pipeline_contracts_plan Group 1 #2 (asset readiness preflight) ──
    # 디버깅 opt-in. 값 "true"/"yes"/"on"/"1" (대소문자 무관) 시 scene_image_pipeline
    # 가 ref asset 누락에도 진행. 그 외는 fail-fast block. ENV ALLOW_TEXT_ONLY_WITHOUT_REFS
    # 우선 (asset_readiness._is_text_only_allowed 참조).
    # type 은 ``str`` — Pydantic ``bool`` 자동 parsing 이 ENV typo (e.g. "garbage")
    # 시 ValidationError 로 앱 import 가 실패하기 때문. parse 는 helper 가 lenient 처리.
    allow_text_only_without_refs: str = ""

    # API 타임아웃 (초) — 용도별 설정
    llm_timeout_text: int = 600       # 텍스트 분석 (시나리오 전문 분석 등, 최대 10분)
    llm_timeout_image_gen: int = 420  # 이미지 생성 (Gemini T2I/i2i, OpenAI gpt-image-2, 7분 — high quality + 긴 prompt 응답이 평균 4분, 여유 7분)
    llm_timeout_validation: int = 120 # 이미지 검증 (GPT Vision, 2분)
    llm_max_retries: int = 3          # 타임아웃/서버에러 시 재시도 횟수 (0=재시도 없음, 총 시도 = 1+N)

    # ── W2-F17 prod fail-fast validators ──

    @field_validator("secret_key")
    @classmethod
    def _secret_key_prod_required(cls, v: str, info) -> str:
        env = info.data.get("environment", "dev")
        if env == "prod" and v in _INSECURE_SECRET_KEYS:
            raise ValueError(
                "SECRET_KEY must be set explicitly in production "
                "(ENVIRONMENT=prod). Default 'dev-secret-key' is not allowed."
            )
        return v

    @field_validator("default_admin_password", "default_creator_password")
    @classmethod
    def _passwords_prod_required(cls, v: str, info) -> str:
        env = info.data.get("environment", "dev")
        if env == "prod" and v in _INSECURE_PASSWORDS:
            raise ValueError(
                f"Default passwords must not be used in production "
                f"(ENVIRONMENT=prod). Override DEFAULT_*_PASSWORD via env."
            )
        return v

    @field_validator("database_url")
    @classmethod
    def _database_url_prod_required(cls, v: str, info) -> str:
        """Codex W2 High 3: prod에서 빈 값/localhost/dev 기본 DSN 거부."""
        env = info.data.get("environment", "dev")
        if env != "prod":
            return v
        if not v or v == _DEV_DEFAULT_DSN:
            raise ValueError(
                "DATABASE_URL must be set explicitly in production "
                "(ENVIRONMENT=prod). Default dev DSN is not allowed."
            )
        if "@localhost" in v or "@127.0.0.1" in v:
            raise ValueError(
                "DATABASE_URL points to localhost in production — "
                "확실하다면 외부 URL을 사용하세요."
            )
        return v

    @field_validator("step_running_timeout_seconds")
    @classmethod
    def _step_running_timeout_positive(cls, v: int) -> int:
        """Block B B4 follow-up: timeout 임계값은 strictly positive 만 허용.

        v <= 0 이면 모든 running row 가 즉시 stale 분류 — Block C atomic claim
        도입 후 multi-worker race / lock 사고 trigger. ENV 오설정을 startup 시
        fail-fast 차단 (silent late-fail 금지 — feedback_no_silent_fallback).
        """
        if v <= 0:
            raise ValueError(
                f"STEP_RUNNING_TIMEOUT_SECONDS must be positive (got {v})"
            )
        return v

    @field_validator("step_lock_lease_seconds", "step_lock_heartbeat_seconds")
    @classmethod
    def _step_lock_intervals_positive(cls, v: int) -> int:
        """lease·간격은 strictly positive.

        0 이하면 모든 하트비트가 즉시 만료로 읽혀 살아있는 락을 빼앗는다.
        ENV 오설정을 startup 에서 fail-fast 로 막는다.
        """
        if v <= 0:
            raise ValueError(f"step lock interval must be positive (got {v})")
        return v

    @field_validator("scene_lvm_sample_rate")
    @classmethod
    def _scene_lvm_sample_rate_range(cls, v: float) -> float:
        """Phase 4 iter 7 follow-up I2: invalid sample_rate 가 T2I 생성 후에야
        ValueError 로 fail 되면 한 번의 잘못된 설정으로 모든 shot 이 이미지
        생성/재시도까지 갔다 죽는다 (비용 낭비). startup 시 fail-fast.
        """
        if v < 0.0 or v > 1.0:
            raise ValueError(
                f"SCENE_LVM_SAMPLE_RATE out of range: {v} (must be 0.0~1.0)"
            )
        return v

    @field_validator("still_image_backend")
    @classmethod
    def _still_image_backend_known(cls, v: str) -> str:
        """오타가 이미지 생성 시점에야 드러나면 비용 낭비 — startup
        fail-fast (roll_count 검증과 같은 이유)."""
        if v not in ("nb2", "grok2", "gpt25"):
            raise ValueError(
                f"STILL_IMAGE_BACKEND unknown: {v!r} "
                f"(must be nb2|grok2|gpt25)")
        return v

    @field_validator("still_cine_provider")
    @classmethod
    def _still_cine_provider_known(cls, v: str) -> str:
        """오타가 변환 시점에야 드러나면 그 샷의 걷기가 죽는다 — startup
        fail-fast (still_image_backend 선례 동형).

        ★모르는 값을 조용히 grok 으로 되돌리지 않는다: 운영자는 reve 로
        돌고 있다고 믿는데 지문·기록은 grok 으로 남아, 나중에 「같은 조건」
        으로 읽히는 산출이 사실은 다른 제공자 것이 된다.
        """
        if v not in ("grok", "reve", "mai", "xai"):
            raise ValueError(
                f"STILL_CINE_PROVIDER unknown: {v!r} "
                f"(must be grok|reve|mai|xai)")
        return v

    @field_validator("still_recipe_roll_count")
    @classmethod
    def _still_recipe_roll_count_range(cls, v: int) -> int:
        """롤 수 오설정이 이미지 생성 시점에야 실패하면 비용 낭비 —
        startup fail-fast. 2026-08-13 사용자 확정(#102)+Codex R1 BLOCK-2:
        a/b 2롤이 계약이다 — 낡은 env(3)가 남으면 롤당 과금이 1.5배 되고
        b·c 가 같은 변주 절을 받아 "전 롤 구도 상이" 원칙도 깨진다.
        내부 경로의 rc=1/2/4 명시 override 는 이 검증과 무관(호출별 인자).
        """
        if v != 2:
            raise ValueError(
                f"STILL_RECIPE_ROLL_COUNT must be 2 "
                f"(a/b 2-roll contract, 2026-08-13): got {v}"
            )
        return v

    @field_validator("scene_lvm_targeted_shot_ids")
    @classmethod
    def _scene_lvm_targeted_shot_ids_normalize(cls, v: str) -> str:
        """Phase 4 iter 7 follow-up M2: zero-padding 정규화. env 의 "05_02"
        같은 padding 된 형식이 trace_meta 의 (5, 2) 와 매칭 못해 silent skip
        되는 것을 방지. parse 시 int()-cast 후 재조립 → "5_2" 로 normalize.
        invalid token (정수 변환 실패) 은 fail-fast.

        iter 2 review M1: scene_index / shot_index 는 production DB 기준
        1-based (MIN=1). "0_0" 또는 "-1_2" 같은 non-positive token 은 운영자
        오입력일 가능성 — 통과시키면 targeted_not_in_list 로 silent skip 되어
        late-discovery. parse 시 fail-fast 로 startup 에서 차단.
        """
        if not v:
            return v
        normalized: list[str] = []
        for raw in v.split(","):
            token = raw.strip()
            if not token:
                continue
            parts = token.split("_")
            if len(parts) != 2:
                raise ValueError(
                    f"SCENE_LVM_TARGETED_SHOT_IDS invalid token {token!r} "
                    f"— expected '<scene_index>_<shot_index>' (e.g. '5_2')"
                )
            try:
                s_idx = int(parts[0])
                t_idx = int(parts[1])
            except ValueError as exc:
                raise ValueError(
                    f"SCENE_LVM_TARGETED_SHOT_IDS non-integer token {token!r}: {exc}"
                ) from exc
            if s_idx <= 0 or t_idx <= 0:
                raise ValueError(
                    f"SCENE_LVM_TARGETED_SHOT_IDS non-positive token {token!r} "
                    f"— scene_index/shot_index are 1-based (DB MIN=1). "
                    f"got scene={s_idx} shot={t_idx}"
                )
            normalized.append(f"{s_idx}_{t_idx}")
        return ",".join(normalized)


settings = Settings()


def has_insecure_defaults() -> list[str]:
    """현재 설정에서 기본값이 남아 있는 항목 리스트. 경고 출력용."""
    issues: list[str] = []
    if settings.secret_key in _INSECURE_SECRET_KEYS:
        issues.append("SECRET_KEY (JWT)")
    if settings.default_admin_password in _INSECURE_PASSWORDS:
        issues.append("DEFAULT_ADMIN_PASSWORD")
    if settings.default_creator_password in _INSECURE_PASSWORDS:
        issues.append("DEFAULT_CREATOR_PASSWORD")
    return issues
