# MBN_STOCK_WEBVIEW 송출 운영·검증 절차

> 2026-07-18 개발 환경 변경: 이 문서의 회차별 승인 절차는 운영/방송 대상에 계속
> 적용한다. 현재 지정된 개발 PC의 Tornado2/PGM 마이그레이션 검증은 사용자가 상시
> 허용했으므로 반복 승인을 요청하지 않는다. 범위와 기술적 중단 조건은
> [개발 환경 상시 권한](DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)을 우선 적용한다.

> 현재 완료·차단 판정은 [`MIGRATION_STATUS.md`](MIGRATION_STATUS.md)가 우선한다. 아래
> Round A~M과 1.0.x 기록은 당시 실행의 역사적 증거로 보존한다.

이 문서는 마이그레이션된 WebView → `IPlayoutEngine` → K3D/Tornado2 경로를 검증할 때의 승인, 실행, 감시, 장애 복구와 rollback 기준이다. 기존 [`PLAYOUT.md`](PLAYOUT.md)의 K3D 설치·해시 핀·격리 진단 규칙을 대체하지 않고, 35개 scene과 PageN 동등성 검증에 필요한 운영 절차를 추가한다.

## 현재 상태

| 항목 | 상태 |
|---|---|
| 지정 개발 PC 기본 모드 | 보호된 `Live`; 승인·설정 불변식 실패 시 기동 차단, 자동 `DryRun` 폴백 없음 |
| 자동 테스트 | 최신 Release x64 .NET solution 3,182/3,182, LegacyParityWeb 150/150, WebPlayout JavaScript 424/424, 원본 scene 기준선 35/35와 staged PGM 정적 계약 통과; 실패·skip 0건 |
| 개발 MSIX UI/DB | 최신 등록 AppX에서 실제 Windows 입력 1,838건으로 UC1~UC7, GraphE, FSell, VIList, ThemeA, EList, PList 모달, 설정·비교 가져오기와 개발 DB 저장→fresh readback→delete/absence를 검증하고 cleanup 완료 |
| 고객 배포 MSIX | 현재 범위에서 보류. 고객용 version·Publisher·서명 인증서·업데이트와 새 PC 로컬 설정 배포는 사용자가 재개할 때 결정 |
| 실제 DB read→DTO→mutation smoke | 34개 도달 가능 data route 통과: 33개 DB + `s5025` trusted 외부 CP949 파일. `s8086` diagnostic과 NXT restore audit를 포함한 Oracle/MariaDB query 58건 통과. 종속 asset·운영 DB-W·PGM 검증은 포함하지 않음 |
| `s5025` trusted 수동 파일 | 외부 CP949 source 통합 검증 통과; 실제 파일/환경은 계속 Git 밖에서 회차별 preflight |
| `s5006` `Video\큐브배경.vrv` | 현재 승인 Cuts root에서 누락 |
| `s6001` 해외지수 영상 | 국가별 `Video\20201008_<국가>.vrv` 13개가 현재 승인 Cuts root에서 누락해 해당 action만 제한. 영상 비의존 두바이유·WTI·브렌트유·금 4 action은 실제 PGM 완료 |
| 이번 마이그레이션 WebView workflow의 실제 Tornado2 검증 | **33/34 runtime route code 완료. 5001/5074, 자산 비의존 30개 route, 5077 20페이지 전체, 5088 전환, s6001 영상 비의존 4 action, retired/final unload 완료. route code 미검증은 s5006뿐이며 s6001 국가 영상 action은 외부자산 대기** |

1.0.2 Visual Studio 개발 등록본을 signed 1.0.3으로 교체할 때 `Remove-AppxPackage -PreserveApplicationData`를 사용했다. 재등록 구간에서 바뀐 파일은 Windows package Settings transaction log `settings.dat.LOG1/LOG2`뿐이었고, WebView LocalState와 외부 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW` 운영 데이터는 삭제·덮어쓰지 않았다. 이 패키지 검증 회차는 기본 설정 부재와 환경 override 0건을 확인한 `DryRun`으로만 실행했으며 Tornado 실제 명령은 보내지 않았다.

1.0.3에서 1.0.4로는 앱 종료 확인창을 통한 정상 종료 뒤 같은 package family에
in-place update했다. remove/reset 없이 비교 8쌍과 one-time marker가 유지됐고,
설치본에서 실제 `삼성` 63건 검색과 10개 업무 탭 상태 유지, 단일 인스턴스를
확인했다. 로그는 `DryRunReady`와 DB Healthy만 기록했으며 playout command는 0건이다.
상세 내용은 [`RELEASE_1_0_4_AUDIT.md`](RELEASE_1_0_4_AUDIT.md)에 있다.

1.0.4에서 1.0.5로도 정상 종료 뒤 같은 package family에 in-place update했다.
새 signed package에서 trusted 자동 refresh 상한 1을 package-context DryRun으로
검증하고 정상 종료 뒤 임시 local config와 모든 playout 환경 override를 제거했다.
상세 내용은 [`RELEASE_1_0_5_AUDIT.md`](RELEASE_1_0_5_AUDIT.md)에 있다.

과거 고정 `5001 → 5006` runner의 실제 PGM 기록은 연결과 기본 K3D 명령 표면의 증거다. 현재 WebView playlist, fresh TAKE IN, Page NEXT, timer refresh, callback/unload의 동등성 완료 증거는 아니다.

2026-07-11의 승인 회차 `MBNWEB-20260711-A`에서는 현재 MSIX WebView runtime으로 `CONNECT → PREPARE 5001/page 1`까지 성공했다. Network Monitoring에 `HELLO`, `LOAD_SCENE`, `BEGIN_TRANSACTION`, 데이터 mutation, `QUERY_VARIABLES`, `END_TRANSACTION`, `SCENE_PREPARE`의 request/success가 기록됐고 PREPARE 동안 PGM은 검은 화면이었다. TAKE IN은 두 번째 K3D Prepare/Play 전에 수행하는 fresh Oracle 조회에서 `DATABASE_UNAVAILABLE`로 명확히 실패했다. `PLAY`가 없고 PGM도 검은 상태임을 확인해 TAKE IN을 반복하지 않았으며, 승인 범위의 `TAKE OUT All` 한 번으로 `STOPAL → UNLOAD_SCENE 5001` 성공을 확인하고 `BYE` 뒤 앱을 정상 종료했다. 직후 probe는 Oracle unhealthy/MariaDB healthy와 정상 DNS/TCP 도달을 보였고, 회차 종료 뒤 전체 55-query 스모크는 두 DB 모두 다시 통과했다. 이 회차는 실패·복구 증거이며 동등성 완료 증거가 아니다. 새 package/계획 SHA-256과 새 Gate A/B로 처음부터 다시 시작한다.

현재 LegacyParityApp은 원본과 같은 10초 주기의 Oracle/MariaDB 상태 감시를 시작·종료 수명주기에 연결하고, 연결 상태가 바뀔 때만 native 로그를 남긴다. 송출 명령, 운영자 입력과 자동 on-air refresh가 시작되면 진행 중인 health 조회를 취소하고 하나의 DB activity gate를 먼저 확보한다. DB health의 `IsTransient`와 숫자 provider error code만 진단 도구에 보존하며 SQL, parameter, host, 계정, 암호, connection string과 provider message는 출력하지 않는다.

### Round G 실패 경계와 Round H 최종 실제 검증

Round G `MBNWEB-20260711-G`는 5001 TAKE IN과 화면 표시까지 성공했지만, 승인된 자동 refresh 1회 예산을 넘어 refresh가 연속 실행되는 경계에서 중단됐다. Network Monitoring에는 성공한 transaction/prepare 39회와 PLAY/`OnScenePlayed` 38회가 기록됐고 FAILURE/ERROR는 각각 0이었다. `refreshLastSuccessAt`은 전진했지만 `playCompletionPending`이 사실상 계속 유지되어 관찰 대기가 timeout됐으며, 5074 playlist NEXT와 Page NEXT는 보내지 않았다. 출력이 5001로 명확히 active이고 `OutcomeUnknown=false`였으므로 승인된 비상 TAKE OUT을 정확히 한 번 보내 STOPALL과 5001 UNLOAD, PGM black, disconnect를 확인했고 재시도는 0회였다.

Round G 증거 경계는 다음 SHA-256으로 고정한다.

| 증거 | SHA-256 |
|---|---|
| 계획 | `E8A6B05E28130665A73CC882A70CD85F023EBF6C59795EC3A56671BE06B06B9A` |
| 회차 결과 | `9B573EBB0F5465164298CA96099E1853DE6E6C00B3D375FB81FC954BED3464A0` |
| manifest | `A0AA8E95CB7F0BE52E21438094D1FFCDA53245C9E33CDC5A659D513EEF19D1EC` |

Round H `MBNWEB-20260711-H`는 이 실패 경계를 수정한 source commit `fa0eae7b61f57f134dfd30ebf1e59e9cc2cccfc5`(`fa0eae7`)와 다음 불변 입력으로 실행했다.

| 항목 | 값 |
|---|---|
| 계획 SHA-256 | `55C7755C2F874B4B012239117D4AB717BBCE194E7DCC8DA9B0E40D094FF8CA19` |
| 회차 결과 SHA-256 | `AB70E61C86909F16FE407AAEEF0A5E668F8A4614375448E860C7BE47D687FA0D` |
| 증적 manifest SHA-256 | `CD4EFD0CF64D9C905D7ABF732296E5A80DD4DE362DC7C88FD0D6967846955494` |
| 서명된 Release x64 MSIX SHA-256 | `D51E8CB637860D9F0377AEE6FF691D7D954BA0FF7E30B1AB65443DA153BC5429` |
| 명령 예산 | CONNECT 1, PREPARE 1, TAKE IN 1, 5001 자동 refresh 1, 5074 playlist NEXT 1, 5074 Page NEXT 1, TAKE OUT 1, disconnect 1, retry 0 |

실제 PGM에서는 5001 삼성전자 화면, 5074 page 1과 page 2를 차례로 확인했고 TAKE OUT 뒤 black을 확인했다. Network Monitoring의 최종 증분은 다음과 일치했다.

| 명령군 | request/success 또는 callback |
|---|---|
| HELLO | 1/1 |
| LOAD 5001 / LOAD 5074 | 각각 1/1 |
| PREPARE 5001 / PREPARE 5074 | 각각 3/3, 2/2 |
| PLAY / `OnScenePlayed` | 4/4, callback 4 |
| STOPALL | 1/1 |
| UNLOAD 5001 / UNLOAD 5074 | 각각 1/1 |
| FAILURE / ERROR | 0/0 |
| BYE | request 1, 별도 success marker 0 |

BYE success marker가 0인 것은 계획의 허용 범위 0~1 안이며, 앱의 disconnected terminal state와 callback drain, 최종 PGM black을 함께 확인했으므로 정상 종료로 판정했다. 전 과정은 `OutcomeUnknown=false`, retry 0이었고 실패 뒤 추정 명령이나 반대 명령을 보내는 rollback은 필요하지 않았다. 정상 cleanup으로 승인된 TAKE OUT 1회, 두 scene unload, disconnect, 앱과 진단 listener 종료, 회차 전용 Live 설정·승인 환경 제거, PGM/Network Monitoring 창 상태 복원을 확인했다. 이후 기본 모드는 다시 `DryRun`이며 장애 rollback은 `Disabled` 전환 또는 직전 승인 패키지 복귀만 사용하고 vendor 설치·등록·라이선스는 변경하지 않는다.

Round H 전에 Core 790, Infrastructure 65, Playout 374개 테스트를 Debug/Release x64에서 각각 모두 통과했고, 실제 Oracle/MariaDB read-only query 55건, Visual Studio 2026 Debug/Release x64와 package build, 서명·설치·package context 검사를 통과했다. 같은 signed MSIX의 package-context DryRun도 `5001 PREPARE → TAKE IN → 자동 refresh 정확히 1회 → 5074 page 1 → page 2 → TAKE OUT`과 최종 IDLE, `OutcomeUnknown=false`, KTAP 미호출을 확인했다.

### Round I 회귀 실패와 native 자동 갱신 상한

Round I `MBNWEB-20260712-I`는 계획 SHA-256 `D221841A15593A987FB944648F0ACE0AC5940936AEE16C74DC9B901A2F45B332`로 CONNECT 1회와 PREPARE 5001/page 1, TAKE IN 1회를 실행했다. 5001 삼성전자 PGM 표시는 확인했지만, 실시간 관찰 자동화가 TAKE OUT까지 하나의 중단 없는 작업으로 묶이지 않아 승인된 자동 refresh 최대 1회를 넘어 9회가 실행됐다. 최종 누적 PLAY/`OnScenePlayed`는 각각 10회(초기 TAKE IN 1 + refresh 9)였다. NEXT와 다른 scene LOAD는 없었고 FAILURE/ERROR는 0이었다. 승인된 TAKE OUT 1회로 STOPALL/UNLOAD 5001 각 1회 성공, PGM black, BYE request 1, 앱 정상 종료를 확인했다. 회차 결과는 `FAIL`, `OutcomeUnknown=false`, vendor command retry 0이며 결과 SHA-256은 `E5FD13E7FCDF87B6ADCFB696EFF2E957B70ECA821A424FC15EE41043CFA0E37E`이다.

로컬 결과 파일은 `artifacts\pgm-evidence\MBNWEB-20260712-I\round-result.json`이고,
적용한 Live 설정 SHA-256은
`BE282B9AF0AC5D8C243234D70587497ADACC7A47ED85C1865ED595949FC8759F`다.
운영 증거 원본은 Git 제외 대상이므로 Gitea에서는 결과 hash만으로 파일을 재취득할
수 없다. 또한 Round I 계획은 자동 refresh 1회를 허용하면서 일부 예상 native
range에는 그 추가 PREPARE/PLAY를 반영하지 않은 불일치가 있었다. 새 계획은 Gate A
뒤 누적 PREPARE 1/PLAY 0, Gate B cap 도달 뒤 누적 PREPARE 3/PLAY 2/
`OnScenePlayed` 2를 명시해야 한다. 종료 직전 native snapshot이 별도 파일로
남지 않은 점도 Round I의 증거 제한으로 보존한다.

이 실패 뒤 `maximumAutomaticRefreshesPerTakeIn` trusted JSON-only 상한을 추가했다. 기본 `null`은 원본 연속 갱신을 유지하고, 실제 검증 설정의 `1`은 scheduler와 실제 refresh dispatch 경계에서 두 번째 실행을 이중 차단한다. 상태는 `refreshCompletedCount`, `refreshMaximumCount`, `refreshLimitReached`를 제공하며, 마지막 refresh `OnScenePlayed`가 drain되기 전에는 count가 상한에 도달해도 `refreshLimitReached=false`를 유지한다. 검증은 `completed=1`, `maximum=1`, `limitReached=true`, `refreshActive=false`, `playCompletionPending=false`를 모두 확인해야 한다. 변경된 package는 Round I 승인/계획을 재사용할 수 없으며 새 package hash, 새 회차 계획과 새 CONNECT/PREPARE/TAKE IN 승인이 필요하다.

### Round J/K/L pre-dispatch 실패와 Round M 교정

Round J `MBNWEB-20260712-J`는 계획 SHA-256
`8B51E43AFC5F0E330A8DEF3B38DF071B071E5814FC96258AA9505935D1B43956`에 대한
Gate A 승인을 정확히 한 번 사용했지만, Windows PowerShell 5.1에서 승인 나이를
`System.DateTime - System.DateTimeOffset`으로 계산하면서 execution marker 전에
실패했다. 결과는 `FAIL_KNOWN_CLEANED`, `OutcomeUnknown=false`다. 앱 launch,
CONNECT, PREPARE, TAKE IN, NEXT, Page NEXT, DB write, TAKE OUT, DISCONNECT와
retry는 모두 0건이다. Gate A 결과 SHA-256은
`BC6FBD867A7BAC01E5785AD6A3B488709FDBC5E778BDF4479D35CA73A02C19F1`이다.
비명령 cleanup은 성공했고 앱/CDP/설정/capability/lease가 남지 않았다. J 승인과
명령 예산은 폐기하며 같은 계획으로 재시도하지 않는다.

Round K `MBNWEB-20260712-K`는 별도 회차이며 계획 SHA-256은
`8B25D2E9987AECF90526752CD9CF9BAB769D2DA4F689064334BE4039FC2E0BC6`이다.
승인 나이는 `[DateTimeOffset]::UtcNow`와 UTC `DateTimeOffset`끼리 계산하고,
`Math.Max`에는 `double` 인수를 명시해 소수초를 보존한다. Windows PowerShell 5.1
회귀시험은 `+5.000초` 허용/`+5.001초` 거부, `900.000초` 허용/`900.001초`
거부와 `60.125초` 보존을 확인한다. Gate A는 승인 파일 생성 뒤 정확히 계획·승인·
Live 설정·installed manifest·계획에 SHA로 묶인 helper 7개만 있는지 계획 해석 전과
capability 생성 직전에 검사한다. 폴더, reparse, 미지·임시·partial·static-test 파일,
누락과 중복은 모두 fail-closed다.

K Gate A는 승인 시간 계산을 통과했지만 설치 runtime manifest의 culture sort가 strict
ordinal 검사를 만족하지 않아 execution marker 전에 `FAIL_KNOWN_CLEANED`로 끝났다.
manifest 289개 항목과 내용은 정상이지만 ordinal 역전이 41개였고 첫 역전은
`clrjit.dll → Config/appsettings.example.json`이다. 앱 launch/CONNECT/PREPARE와
금지 명령은 모두 0건이며 `OutcomeUnknown=false`다. 결과 SHA-256은
`E17FE1EE451A5D9E2B73CCB3D039B5AA90998CC35E3E973148614D32E76D1482`다. K 승인과
예산은 폐기하며 재사용하지 않는다.

Round L은 289개 path/length/SHA-256을 그대로 두고 `[StringComparer]::Ordinal`로만
재정렬했다. manifest SHA-256은
`9631E8188E10F23970F43593B705957A215AE01E2760FE714B08518DB0571A9B`이며 역전 0,
K/L 의미 집합 289/289 동일, `ko-KR/en-US/tr-TR` 불변성을 확인했다. Gate A/B 모두
exact schema, NFC와 안전 경로, exact/대소문자 충돌, strict ordinal 순서를 검증한다.
Node 계획 생성기의 18자리 start ticks 반올림도 승인 전에 발견해 Int64 보존 회귀를
추가했다. 최종 L 계획 SHA-256은
`23E3A6A5400166A47835E146DCC2D19C181E2100D1485DB428F1B10718D89928`이다. 승인 전
운영 폴더는 정확히 10개 파일이며 앱/Tornado 명령은 0건이다. Gate A 예산은 CONNECT
1회, PREPARE 5001/page 1 1회, 명확한 known-outcome 중단의 DISCONNECT 최대 1회다.
TAKE IN, NEXT, Page NEXT, DB write와 retry는 0이다. 세부 계획은
[`LIVE_VALIDATION_PLAN_MBNWEB-20260712-L.md`](LIVE_VALIDATION_PLAN_MBNWEB-20260712-L.md)에
고정했다.

승인된 Round L Gate A는 manifest와 시간·Int64 검사를 통과했지만 frozen PGM
handle 592052가 `visible=true`, `iconic=true`여서 strict desktop target 검사에서
execution marker 전에 종료됐다. 결과는 `FAIL_KNOWN_CLEANED`,
`outcomeUnknown=false`이며 앱 launch, CONNECT, PREPARE, TAKE IN, NEXT, DB write,
DISCONNECT와 retry는 모두 0건이다. Gate A 결과 SHA-256은
`F57BEA9E5F4A215DBB944DCFDC22D8989A5418BDC3910B19B2A11ED9B795FE4D`다. L 승인과
예산은 폐기했고 재사용하지 않는다.

새 Round M은 PID/start/exe/signature/listener와 유일한 exact 창 identity를 먼저
검증한 뒤, 필요한 창에만 동기 `ShowWindow`를 창별 최대 1회 적용한다. 성공 판정은
API 반환값이 아니라 5초 이내 visible/non-iconic/non-cloaked 사후 상태다. 두 창의
원본 placement는 첫 변경 전에 메모리에 보존하며 helper 내부 부분 실패, execution
marker 전 Gate A 실패와 marker 이후 실패 모두 exact 원상복구 경로가 있다. preflight
동안 파일, capability, 앱 명령과 벤더 명령은 0건이다. schema 4 계획은 19,042 bytes,
SHA-256 `7A1141C5F276729E340227CEEAA48B0E6018632BBA51F43AA94636084AA3C301`이며
승인 전 실제 명령은 0건이다. 세부 계획과 정확한 승인문은
[`LIVE_VALIDATION_PLAN_MBNWEB-20260712-M.md`](LIVE_VALIDATION_PLAN_MBNWEB-20260712-M.md)에
고정했다.

이 실제 Live PGM 증거는 회차에 허용된 5001과 5074에 한정된다. 35개 scene builder 전체 동등성은 자동 테스트, 55-query 실데이터 smoke와 [`SCENE_EQUIVALENCE.md`](SCENE_EQUIVALENCE.md) 매트릭스로 유지하며, 나머지 scene이 실제 PGM에서 각각 송출됐다는 의미로 기록하지 않는다.

## 화면/계약과 운영 검증의 분리

- 화면·native bridge·DTO·mutation·identity 상관 계약이 존재하고 자동 테스트가 통과한 상태를 화면/계약 구현으로 기록한다.
- PList의 기존 한국어 `DC_TITLE`/`LIST_TEXT` 2,213행은 모두 목록과 scene alias로 로드된다. 최신 fresh typed selection은 1,617행, 식별정보 부족 보류는 596행이며, PList와 PROGRAM named playlist의 개발 DB create/save→fresh readback→delete/absence를 최신 AppX 실제 입력으로 확인했다. 이는 보류 행의 asset/PREPARE/PGM 완료나 운영 DB 포괄 쓰기 허가를 뜻하지 않는다.
- ThemeA/EList는 최신 `catalog` 299입력 회차에서 KRX/NXT·동명이름·코드 경계, selected-row 편집·저장·fresh readback·삭제·absence와 cleanup을 개발 DB에서 확인했다.
- 원본 `종목비교.dat` 8쌍은 signed package에서 one-time 실행·재시작 영속성을 확인했다. FSell/VI는 격리 import fixture와 현재 운영 파일 read-only 불변 검증을 마쳤지만 기존 운영 파일은 덮어쓰지 않았다.
- GraphE·PList/AList·ThemeA·EList의 개발 DB CRUD는 승인된 임시 식별자로 완료했다. 실제 PGM은 승인된 action 범위에서 33/34 runtime route code를 검증했고 route code 미검증은 `s5006`뿐이다. `s6001`은 영상 비의존 4 action을 완료했으며 국가 영상 action은 외부자산 대기다. 운영 DB 포괄 write와 고객 환경은 별도 범위다.

## 절대 안전 규칙

1. 지정 개발 PC의 앱은 보호된 `Live`로만 기동하고 `DryRun`으로 전환하지 않는다.
2. `--probe`와 `--test-plan`은 `KTAPConnect`를 호출하지 않는다. 이때 Tornado2 Network Monitoring에 기록이 없는 것이 정상이다.
3. 실제 출력은 허용된 테스트 scene, 지정된 PGM, 지정된 회차 범위에서만 수행한다.
4. 운영·방송 또는 비지정 환경은 아래 Gate A/B의 회차별 승인을 적용한다. 현재 지정 개발 PC는 [개발 환경 상시 권한](DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)의 범위·대상 확인·기술적 중단 조건을 적용하며 별도 대화 승인을 반복하지 않는다.
5. 회차 범위에 없는 NEXT, Page NEXT, TAKE OUT 이외 명령 또는 다른 scene으로 범위를 넓히지 않는다.
6. timeout, `OutcomeUnknown`, `WEB_TIMEOUT`, refresh fault, 응답/화면 불일치, 대상 프로세스 변경, callback 누락 상태에서는 같은 명령을 자동 또는 수동으로 반복하지 않는다.
7. 실제 검증 회차에는 `reconnectEnabled=false`, `maximumReconnectAttempts=0`을 사용한다. 재접속은 기존 회차를 재사용하지 않고 대상과 상태를 다시 확인한 새 회차로만 수행한다.
8. 안전 게이트, x64 vendor hash pin, scene allowlist, process/window/port ownership 검사를 완화하지 않는다.
9. Web 입력으로 object 이름, 파일 경로, K3D method, 임의 SQL을 받지 않는다.
10. 실제 on-air일 수 있는 scene을 수동으로 unload하거나 COM RCW를 강제 release하지 않는다.
11. pending Play callback이 있으면 TAKE OUT 이외의 PREPARE/TAKE IN/NEXT/timer refresh를 실행하지 않는다. lifecycle callback이 남아 있으면 Disconnect하지 않는다.

## Git 밖에서 준비할 항목

다음 항목은 로컬 운영 설정 또는 승인된 외부 자산이다. Git, MSIX, 로그 첨부, 테스트 fixture에 복사하지 않는다.

- 원본 test Cuts root: `C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts`
- 실제 `.t2s`, image, texture, video와 기타 scene 자산
- `s5006`의 상대 자산 `Video\큐브배경.vrv`와 `s6001` 해외지수의 국가별 영상 13개; 현재 위 Cuts root에는 없으므로 제공 전 해당 자산 의존 action의 실제 PREPARE 금지
- 정상 앱의 FSell/VI private 저장소 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\OperatorData`. 원본 `Data`를 strict CP949·hash·durable intent로 검증해 이 위치로 이전하는 one-time importer는 구현했지만, 현재 운영 profile의 기존 파일을 덮어쓰거나 marker를 임의 생성하지 않음
- Oracle/MariaDB host, SID/service, database, user, password와 운영 query selector
- K3D/Tornado vendor DLL, Interop, license와 설치 경로
- `MBN_STOCK_K3D_NATIVE_SHA256`, `MBN_STOCK_K3D_INTEROP_SHA256` 승인 값과 승인 근거
- MSIX 서명 인증서, 개인 키, 암호와 배포용 secrets
- 실제 Tornado host/port, output channel, PGM 창 정보와 운영 설정
- Network Monitoring/PGM screenshot·영상·manifest 등 실제 방송 증거

Cuts root는 이번 작업에서 읽기 전용으로 취급한다. [`Test-LegacyCutCoverage.ps1`](../scripts/Test-LegacyCutCoverage.ps1)은 기존 45개 active alias와 [`LegacyCutRequirements.json`](../scripts/LegacyCutRequirements.json)의 builder 소유 image·texture·video를 함께 검사하며 전체 회귀와 최신 패키지에 반영됐다. 절대 경로, root 탈출, 누락·빈 파일과 root부터 파일까지의 reparse point를 실패로 보고한다. 현재 승인 Cuts에서는 `s6001`의 서로 다른 국가 영상 13개와 `s5006`의 `Video\큐브배경.vrv`만 optional `Missing`으로 명시되는 것이 정상이며, 누락 상태에서는 해당 자산 의존 action의 PREPARE로 진행하지 않는다.

## 운영·비지정 환경 승인 게이트

아래 Gate A/B는 운영·방송 대상 또는 현재 지정 개발 PC가 아닌 환경에 적용한다. 지정 개발
Tornado2/PGM의 마이그레이션·동등성 검증은 [개발 환경 상시 권한](DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)을
사용해 회차 범위와 대상 식별을 증거에 고정하되, 사용자에게 동일 범위의 대화 승인을 다시 요구하지 않는다.

### Gate A: 회차 범위 승인

Connect 또는 실제 PREPARE 전에 다음 내용을 운영 기록에 남긴다.

```text
[MBN_STOCK_WEBVIEW Tornado 검증 회차]
회차 ID:
예정 시각/최대 종료 시각:
대상 Tornado2/PGM 식별값:
승인된 test cut alias:
승인된 selector와 시작 page:
허용 동작과 횟수: CONNECT, PREPARE, TAKE IN, NEXT/Page NEXT, timer refresh 관찰, TAKE OUT, DISCONNECT
Network Monitoring/PGM 관찰 담당자:
비상 TAKE OUT 담당자:
```

대상, cut, selector, page, 횟수 중 하나라도 바뀌면 같은 회차 승인을 재사용하지 않는다.

#### 5001 PREPARE 전용 one-shot Gate A

현재 5001/page 1 연결 검증은 [`New-LegacyPgmPreparePlan.ps1`](../scripts/New-LegacyPgmPreparePlan.ps1),
[`Invoke-LegacyPgmPrepareGate.ps1`](../scripts/Invoke-LegacyPgmPrepareGate.ps1),
[`Test-LegacyPgmPrepareGate.mjs`](../scripts/Test-LegacyPgmPrepareGate.mjs)의 고정 계약을 사용한다.
계획 생성과 `ValidatePlan`은 명령을 보내지 않으며, 실행은 clean/pushed `main`, 정확한 Release x64
MSIX와 package runtime, 원본 `5001.t2s`, K3D 등록, PGM PID/시작 시각/listener, 15분 이내의
별도 승인 파일이 모두 일치할 때만 열린다. 실제 DTO를 만드는 로컬 `database.local.json`도 내용이나
비밀번호를 노출하지 않고 경로·크기·SHA-256으로 고정해 실행 동안 read lease를 유지한다.

이 게이트의 허용 예산은 현재 PGM `CONNECT` 1회와 삼성전자 `5001` page 1 `PREPARE` 1회뿐이다.
`TAKE IN`, `NEXT`, Page NEXT, `TAKE OUT`, `DISCONNECT`, 자동 재연결, DB write와 재시도는 모두 0회다.
앱은 WebView 생성 전에 process-lifetime 권한을 캡처하고, native engine은 동일 권한으로 exact PGM,
exact cut lease, exact Live profile과 SDK dispatch 직전 만료를 다시 검사한다. Gate A 동안 DB mutation,
로컬 영구 저장, Fade와 배경 변경도 native 경계 전에 거부한다.
Oracle/MariaDB 설정 override와 managed runtime startup hook/profiler 환경값은 process/user/machine
어느 범위에서도 허용하지 않으며 app과 helper의 상속 환경에서도 제거한다.

raw one-shot capability는 명령줄·계획·증적·Web 상태에 기록하지 않는다. package-context wrapper와
Node helper는 current-user ACL named pipe로 각 parent가 OS PID·실행 파일·시작 시각·package/parent
identity를 확인한 직후에만 필요한 값을 한 번 받는다. 감시 실패나 제한 시간 초과에서는 pipe를 먼저
폐기하고 control-plane helper만 fail-stop한다. 이는 앱, PGM, Tornado 또는 K3D 프로세스를 종료하거나
vendor cleanup 명령을 보내는 권한이 아니다.

성공 시 앱과 session은 `PREPARED 5001` 상태로 그대로 두고 별도 승인 없는 cleanup을 하지 않는다.
결과가 불명확하면 helper 증적을 `OutcomeUnknown`으로 남기고 앱 종료, 반대 명령, 재실행 또는 같은
PREPARE 반복을 하지 않는다. 다음 단계는 Network Monitoring과 PGM을 사람이 확인한 뒤 새 계획과
새 승인을 만드는 것이다.

### Gate B: TAKE IN 직전 승인

PREPARE 결과와 Network Monitoring 상태를 운영자가 확인한 후, TAKE IN 직전에 다음과 같이 명시적인 승인을 받아야 한다.

```text
회차 <ID>의 준비된 cut <alias>, page <N>을 현재 PGM에 TAKE IN 1회 실행하는 것을 승인한다.
```

이 문구가 없거나 회차 ID/cut/page가 다르면 TAKE IN을 실행하지 않는다. 이전 대화의 일반적 동의, 과거 회차 승인, DryRun 성공은 Gate B를 충족하지 않는다.

## 실제 연결 전 offline preflight

아래 단계는 Tornado에 명령을 보내지 않는다.

1. 원본 기준선과 cut alias를 확인한다.

   ```powershell
   powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\Test-LegacySceneBaseline.ps1
   powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\Test-LegacyCutCoverage.ps1 `
     -CutRoot "C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts"
   powershell -NoProfile -ExecutionPolicy Bypass -File .\tests\Scripts\Test-LegacyCutCoverage.Fixture.ps1
   ```

2. Debug/Release x64 테스트와 Web bridge 검사를 실행한다.

   ```powershell
   dotnet test .\tests\MBN_STOCK_WEBVIEW.Core.Tests\MBN_STOCK_WEBVIEW.Core.Tests.csproj -c Debug -p:Platform=x64
   dotnet test .\tests\MBN_STOCK_WEBVIEW.Core.Tests\MBN_STOCK_WEBVIEW.Core.Tests.csproj -c Release -p:Platform=x64
   dotnet test .\tests\MBN_STOCK_WEBVIEW.Playout.Tests\MBN_STOCK_WEBVIEW.Playout.Tests.csproj -c Debug -p:Platform=x64
   dotnet test .\tests\MBN_STOCK_WEBVIEW.Playout.Tests\MBN_STOCK_WEBVIEW.Playout.Tests.csproj -c Release -p:Platform=x64
   dotnet test .\tests\MBN_STOCK_WEBVIEW.Infrastructure.Tests\MBN_STOCK_WEBVIEW.Infrastructure.Tests.csproj -c Debug -p:Platform=x64
   dotnet test .\tests\MBN_STOCK_WEBVIEW.Infrastructure.Tests\MBN_STOCK_WEBVIEW.Infrastructure.Tests.csproj -c Release -p:Platform=x64
   powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\Test-WebPlayout.ps1
   ```

3. 실제 DB smoke는 read-only query만 사용하는 별도 단계로 실행한다. DB 오류가 있으면 Tornado 단계로 넘어가지 않는다.

   ```powershell
   dotnet run --project .\tools\MBN_STOCK_WEBVIEW.DbSmoke\MBN_STOCK_WEBVIEW.DbSmoke.csproj -c Release --no-restore
   ```

4. 설치된 trusted Release x64 MSIX가 package context에서 실행되는지 확인한다. `bin` 또는 loose EXE를 실제 검증에 사용하지 않는다.
5. K3D Registry64, AMD64 PE, TypeLib/Interop metadata, 두 SHA-256 pin, license를 확인한다. DLL을 repo 또는 앱 폴더로 복사해 우회하지 않는다.
6. 정확히 하나의 승인 대상 Tornado2 프로세스, PGM 창, Network Server TCP port와 LISTEN 소유권을 확인한다. 매뉴얼 예시 port를 추정해 사용하지 않는다.
7. `s5025`를 선택했다면 trusted 외부 디렉터리와 파일 preflight가 성공해야 한다. `s5006`은 `Video\큐브배경.vrv`, `s6001` 해외지수는 선택 국가에 해당하는 `Video\20201008_<국가>.vrv`가 승인 asset root 안에 있어야 한다. coverage 결과의 `MissingAssets`, `RootEscapes`, `ReparseAssets` 중 하나라도 0이 아니면 해당 장면으로 진행하지 않는다.
8. 실제 COM을 사용하는 Test/Live playlist의 모든 cut이 로컬 폐쇄형 allowlist 안에 있고 selector가 closed enum/lookup 규칙을 통과하는지 정적 계약 테스트에서 확인한다. allowlist가 비어 있으면 실제 모드 초기화 자체를 거부해야 한다.
9. 로컬 playout 설정의 `legacySceneFadeDuration` 기본값 6과 `legacySceneBackgroundKind`를 확인한다. 공통 background를 쓸 때 `legacySceneBackgroundAssetPath`는 `sceneDirectory` 아래의 승인된 상대 경로여야 한다. `PlayoutSceneCompositionFactory` preflight가 파일 존재, 허용 확장자, root 탈출, 절대 경로와 reparse point를 모두 거부하는지 확인한다. Web에는 이 asset 경로를 보내지 않는다.
10. Web 계약 테스트에서 45개 active alias, row별 `enabled`, PREPARE 뒤 snapshot freeze, current entry/builder/page size/current rows/last-page/preview와 refresh 상태를 확인한다. pending command와 `OutcomeUnknown`/timeout quarantine에서도 playlist 편집이 잠겨야 한다. preview에 image/texture/video 경로가 나타나면 실제 회차를 중단한다.

하나라도 실패하면 회차를 시작하지 않는다. 설정을 수정한 뒤 처음부터 새 preflight 결과를 만든다.

## 정상 실행 순서와 관찰점

### 1. Connect

- Gate A와 모든 preflight를 다시 확인한다.
- Connect는 한 번만 보낸다.
- `OnHello`와 Network Monitoring의 request/response를 함께 확인한다.
- Connect 결과가 accepted이지만 `OnHello` 또는 monitor 왕복이 확인되지 않으면 준비 완료로 간주하지 않는다.

### 2. PREPARE

PREPARE는 다음 native 순서를 수행해야 한다.

1. 현재 활성 playlist 항목과 page 0을 native loader가 조회한다.
2. scene load, trusted common background와 기본 fade 6 또는 승인된 로컬 fade/scene effect를 적용한다.
3. `BeginTransaction` → allowlisted mutation → `QueryVariables` → `EndTransaction`을 실행한다.
4. layout 10을 `Prepare`한다.

관찰자는 Network Monitoring의 load/prepare 관련 왕복이 한 번씩인지, 앱이 `Prepared`와 정확한 cue/page를 표시하는지 확인한다. PGM에 의도하지 않은 on-air 변화가 있으면 즉시 회차를 중단하고 장애 절차를 따른다.

### 3. TAKE IN

- Gate B를 받은 뒤 한 번만 실행한다.
- PREPARE 때의 DTO를 재사용하지 않고 frozen entry/page를 실제 DB에서 새로 조회하는지 확인한다.
- fresh scene의 `LoadScene` → transaction → `Prepare(10)`이 명확히 성공한 뒤 `Play(10)`이 한 번 실행되는지와 `OnScenePlayed`를 확인한다.
- PGM에서 원본과 비교할 object 이름별 값, 표시 상태, 색, 위치/크기, crop, path, image/texture/video, fade를 기록한다.
- Network Monitoring의 두 번째 load/prepare와 PLAY request/response, PGM 화면 시각을 같은 증거 묶음에 보존한다.

### 4. NEXT

앱이 반환한 `nextKind`가 기준이다. Web에서 임의로 page/playlist 유형을 정하지 않는다.

- `PageNext`: playlist index를 유지하고 원본 `Next_Scene(0)`처럼 다음 page의 fresh 데이터를 조회한 뒤 새 scene을 `LoadScene` → transaction mutation → `QueryVariables` → `EndTransaction` → `Prepare(10)` → `Play(10)`한다. 이 경로에서 `GetPlayingScene` in-place 갱신을 기대하지 않는다.
- `PlaylistNext`: 현재 page가 마지막일 때 다음 활성 playlist 항목을 load/prepare/play한다. 비활성 항목은 앞으로 건너뛰고 끝에서 wrap하지 않는다.
- 각 NEXT는 Gate A에 승인된 유형과 횟수 안에서만 한 번씩 실행한다.
- `pageIndex`, `pageCount`, `itemCount`, 마지막 부분 page의 빈 object clear/hide와 PGM 화면을 함께 확인한다.
- 모든 operator command는 timer를 먼저 멈춘다. TAKE IN/playlist NEXT 뒤에는 원본 `m_time`으로 첫 timer가 시작되지만 Page NEXT 성공 뒤에는 timer가 정지 상태로 남아야 한다.

### 5. Timer refresh

- 승인 범위에 포함된 경우에만 자동 refresh를 관찰한다. 현재 안전 스케줄러는 직전 `OnScenePlayed` callback이 lifecycle queue에서 drain된 뒤에만 one-shot cooldown을 시작한다. 5001의 첫 cooldown은 온전한 2초이고, 성공한 refresh 뒤에는 callback drain 후 온전한 3초 recurring cooldown이다. 따라서 실제 dispatch 간격은 callback/drain 시간과 cooldown의 합이며, 원본의 절대 3초 cadence보다 의도적으로 길다.
- 실제 검증 회차는 trusted JSON에 `maximumAutomaticRefreshesPerTakeIn`을 승인 횟수와 동일하게 고정한다. Web과 환경 변수는 이 값을 변경할 수 없으며, 상한 도달 후 마지막 callback까지 drain되면 loop가 fault 없이 `CAPPED`로 끝난다.
- 이 callback-aware cooldown은 recurring refresh를 제거한 것이 아니다. missed tick을 누적하거나 따라잡지 않고 합치며, refresh는 최대 하나만 in-flight다. 각 실행은 current entry/page의 fresh DB DTO를 사용해 retained on-air scene에 `BeginTransaction` → mutation → `QueryVariables` → `EndTransaction` → `Prepare(10)` → `Play(10)`을 한 번 수행한다.
- timer refresh는 scene-level background와 fade effect를 다시 적용하지 않는다.
- 앱의 `refreshActive`, `refreshNextAt`, `refreshLastSuccessAt`, `refreshCompletedCount`, `refreshMaximumCount`, `refreshLimitReached`, fault code/message와 PGM 데이터를 함께 기록한다.
- refresh가 실패하거나 timeout/unknown이면 fault latch가 켜지고 반복이 즉시 끝나야 한다. TAKE OUT 이외의 명령을 시도하지 않는다.
- 명확히 성공한 TAKE OUT 뒤 native refresh state가 reset되면 전용 refresh fault marker만 사라져야 한다. `OutcomeUnknown`, `WEB_TIMEOUT` 또는 native quarantine 표시는 함께 reset되면 안 된다.
- operator NEXT는 먼저 현재 refresh epoch를 원자적으로 stop한다. 그 시점에 Play callback이 pending이면 DB activity 대기나 scene query 전에 `PLAY_CALLBACK_PENDING`으로 즉시 fail-fast하고 자동 재시도하지 않는다. 명령 gate가 바로 풀리므로 TAKE OUT은 계속 사용할 수 있다. callback이 완료된 cooldown 구간에서는 NEXT를 정상 실행할 수 있다.
- refresh epoch의 cancellation과 status는 같은 lock에서 교체·중지·갱신·완료한다. Stop 뒤 늦은 `onDelay`, 이전 세대의 success/fault/finally가 현재 세대나 stopped 상태를 다시 활성화할 수 없다.

### 6. TAKE OUT과 Disconnect

- 승인된 TAKE OUT 한 번으로 `TakeOut(All)`/`StopAll`을 실행한다.
- `OnCutOut` 또는 `OnStopAll`, 앱 state clear와 PGM의 검은 화면 또는 승인된 종료 상태를 확인한다.
- pending callback과 retired scene 정리가 끝난 뒤에만 Disconnect한다.
- Network Monitoring에서 stop 계열 왕복과 BYE/disconnect를 확인한다.

## Network Monitoring과 PGM 증거 체크리스트

vendor monitor의 실제 명령 표기는 버전에 따라 다를 수 있으므로 추정 문자열로 성공을 만들지 않는다. raw 화면과 시각을 보존하고 다음 의미 단위로 대조한다.

| 단계 | Network Monitoring | PGM/앱 |
|---|---|---|
| Connect | HELLO 또는 대응 connect request/response 한 쌍 | 앱 Connected, 대상 process generation 일치 |
| PREPARE | scene load, mutation transaction, prepare의 중복 없는 왕복 | Prepared cue/page/row 수 일치, 의도하지 않은 on-air 없음 |
| TAKE IN | fresh DB 결과의 두 번째 load/prepare 뒤 PLAY request/response | `OnScenePlayed`, 실제 object 값과 시각 상태 일치 |
| Page NEXT | 같은 alias의 새 scene load/transaction/prepare/play 왕복 | playlist index 유지, page만 +1, 빈 slot clear/hide, refresh timer 정지 |
| Playlist NEXT | 다음 cut load/prepare/play 왕복 | 다음 활성 항목과 page 0 표시 |
| Timer refresh | retained on-air scene의 update transaction, prepare, PLAY 1회의 중복 없는 왕복 | callback drain 뒤 첫 full `m_time`, 이후 callback drain 뒤 full 3초 cooldown, 같은 entry/page의 fresh 값, refresh 상태 정상 |
| TAKE OUT | STOP/STOPALL 대응 왕복 | `OnCutOut`/`OnStopAll`, 승인된 종료 화면 |
| Disconnect | BYE 또는 대응 disconnect 왕복 | callback pending 0, 연결 해제 |

회차 증거에는 다음을 포함한다.

- 회차 ID, package version, Git commit, 실행 시각과 timezone
- cut alias, closed selector, page index/count, DB 조회 식별값; password와 connection string 제외
- 각 앱 명령의 결과 code와 `OutcomeUnknown` 여부
- 같은 시각의 Network Monitoring과 PGM 화면
- PREPARE/TAKE IN/NEXT/timer refresh/TAKE OUT 전후 screenshot 또는 연속 capture
- callback 순서, retired/unloaded scene 수, disconnect 결과
- Web의 frozen entry/builder/page size/current rows/last-page/preview와 refresh status; asset path는 제외
- 운영자 최종 판정과 불일치 목록

증거는 Git 제외 로컬 경로 또는 승인된 증거 저장소에 보관한다. 캡처에 host, port, 계정, license 정보가 보이면 외부 전달 전에 별도 보안 절차로 처리하며 원본 증거를 repo에 넣지 않는다.

## 장애 판정과 반복 금지

| 상황 | 즉시 조치 | 재시도 조건 |
|---|---|---|
| 승인/설정/allowlist 불변식 실패, COM 호출 전 명시적 `Rejected` | Live 엔진 기동 차단, 원인 기록 | offline 수정과 전체 preflight 후 새 실행 |
| DB/K3D/PGM 일시 장애 | Live 엔진을 `Disconnected`/`Faulted`로 유지하고 명령 전송 중단 | 원인 수정 후 known state에서 명시적 CONNECT |
| Connect timeout 또는 `OnHello`/monitor 불일치 | 결과를 불명확으로 격리, Connect 반복 금지 | 운영자가 세션과 process/port 상태를 확인한 뒤 새 회차 승인 |
| PREPARE/TAKE IN/NEXT/refresh/TAKE OUT timeout | `OutcomeUnknown`으로 취급, 같은 명령과 반대 명령 자동 실행 금지 | PGM/monitor/콜백을 사람이 확인하고 출력 안전을 복구한 뒤 새 회차 승인 |
| `WEB_TIMEOUT` | strict timeout-quarantine message를 native에 전달. MainWindow가 process-lifetime latch를 먼저 세우고 vendor session을 quarantine; 늦은 응답, UI 재시도와 WebView reload로 해제 금지 | native status와 PGM을 사람이 대조하고 process를 새로 시작한 뒤 새 승인 |
| refresh DB/scene/COM fault | refresh loop 중단, fault latch 유지, TAKE OUT 외 명령 금지 | 정상 TAKE OUT과 실제 화면 확인 후 offline 원인 수정, 새 회차 승인 |
| 명령 성공 응답과 PGM 화면 불일치 | 앱 state를 신뢰하지 말고 회차 중단 | PGM 운영자가 실제 상태를 판정하고 새 회차 승인 |
| callback 누락/지연 | scene을 unload하거나 disconnect 강제하지 않음 | callback 도착 또는 운영자의 수동 안전 판정 후 새 회차 |
| Tornado process generation, 창, LISTEN 소유권 변경 | 모든 자동 동작 중지, 대상 격리 | 새 process를 처음부터 검증하고 새 승인 |
| 연결 장애/Disconnect timeout | 자동 reconnect 금지, 연결·출력 상태를 불명확으로 기록 | 운영자가 Tornado 세션을 확인한 뒤 새 회차 |

`OutcomeUnknown`은 단순 실패 code가 아니라 실제 출력 결과를 모른다는 latch다. Cancelled, Rejected, `WEB_TIMEOUT` 또는 UI 오류로 낮추지 않는다. timeout quarantine은 native process-lifetime latch이므로 WebView reload나 오류 창으로 해제되지 않는다. refresh fault marker는 성공한 TAKE OUT 뒤 native reset에만 맞춰 제거할 수 있지만 unknown/quarantine latch에는 영향을 주지 않는다. process를 재시작해 표시를 지우는 행위도 실제 출력 복구가 아니다.

GraphE, ThemeA/EList, FSell/VIList 또는 이름 있는 플레이리스트 쓰기에서 commit·파일 교체 결과나 browser 응답 상관관계를 확인하지 못한 경우에도 동일한 원칙을 적용한다. 어느 한 subsystem에서 `OutcomeUnknown`이 발생하면 네이티브 global operator-mutation quarantine이 모든 operator write를 프로세스 수명 동안 차단한다. 다른 편집 화면으로 옮겨 쓰거나 같은 값을 다시 저장하지 않는다. 앱을 종료한 뒤 Oracle `INPUT_*`/`SB_*`/`EXPERT_*`/`DC_LIST`/`PLAY_LIST` 또는 trusted local file을 읽기 전용으로 대조하고, 실제 결과를 확정·복구한 다음 새 앱 프로세스와 새 명시적 작업으로 시작한다. 송출 engine/workflow가 없거나 IDLE을 양쪽에서 증명하지 못하는 상태, PREPARED/PROGRAM, callback pending, refresh, shutdown 중에도 모든 operator mutation은 fail closed다.

## Rollback과 unload

### 명확히 출력 전 실패한 경우

COM 명령 전 validation에서 명시적으로 거부됐고 Network Monitoring/PGM에 변화가 없음을 확인한 경우에만 offline 수정으로 돌아간다. 수정 후에는 기존 회차를 이어가지 않고 새 회차로 시작한다.

### 출력이 명확히 active이고 엔진이 정상인 경우

해당 회차에서 명시적으로 승인된 TAKE OUT을 한 번 실행한다. 현재 M 계열에서는 이
권한이 Gate B에만 있다. 성공 callback과 PGM 종료 화면을 확인한 뒤 승인 범위의
Disconnect를 실행하며, 같은 TAKE OUT을 확인용으로 반복하지 않는다.

### 결과가 불명확한 경우

1. 앱에서 추가 PREPARE, TAKE IN, NEXT, timer refresh, TAKE OUT, Disconnect를 자동으로 보내지 않는다.
2. 현재 앱 status, 마지막 명령, Network Monitoring, PGM을 캡처한다.
3. 방송 운영자가 Tornado 본 프로그램/PGM에서 실제 출력과 세션을 판정한다.
4. 필요한 수동 정리는 방송 운영 권한과 현장 절차로 수행한다. Codex나 앱이 임의 명령을 추정하지 않는다.
5. 안전한 black/approved fallback과 세션 종료가 사람에게 확인된 뒤 앱을 종료한다.
6. 장애 원인과 조치가 확정될 때까지 새 검증을 승인하지 않는다.

### Scene 수명 규칙

- `OnScenePlayed`가 새 scene의 재생을 확정하면 이전 scene만 retired queue로 이동한다.
- `OnCutOut`은 해당 layout, `OnStopAll`은 전체 player의 on-air 참조를 정리할 근거다.
- pending Play callback이 있으면 TAKE OUT 이외의 명령을 fail-closed 차단한다. pending `OnScenePlayed`/`OnCutOut`/`OnStopAll`이 있으면 Disconnect와 unload를 시도하지 않는다.
- `CutOut`/`StopAll` pending counter는 SDK dispatch 직전에 증가하고 동기 호출 실패 시 감소한다. 성공 callback은 대응 counter를 하나 감소시키고 중단된 Play의 pending accounting을 취소한다. dispatch 뒤 cancellation/timeout이면 counter 상태를 성공으로 추정하지 않는다.
- 이전 connection generation에서 늦게 온 callback은 현재 generation의 state나 scene을 정리하는 근거로 쓰지 않는다.
- 장기 실행 중 retired scene은 callback으로 안전성이 확인된 뒤 STA queue 안에서 `Unload`와 release를 수행한다.
- 강제 GC, 임의 RCW release, 현재 on-air scene unload를 rollback으로 사용하지 않는다.

## 완료 판정

실제 검증 회차는 다음을 모두 만족할 때만 성공이다.

1. Gate A와 Gate B가 같은 회차에 기록돼 있다.
2. 허용된 test cut과 selector만 사용했다.
3. PREPARE → fresh DB TAKE IN → 승인된 callback-aware timer refresh → Page NEXT/playlist NEXT → TAKE OUT 순서가 중복 없이 완료됐다. refresh는 callback drain 뒤 full initial/recurring cooldown과 회차 예산을 지켰다.
4. 모든 결과가 명확한 성공이며 `OutcomeUnknown=false`다.
5. Network Monitoring request/response와 PGM 화면이 같은 시각 증거로 남아 있다.
6. 원본과 object 값, 표시, 색, 위치/크기, crop, path, asset, fade, page와 refresh 상태가 일치한다. Web preview에는 실제 asset path가 없다.
7. callback과 unload 순서가 정상이고 Disconnect가 성공했다.
8. DB password, vendor DLL/license, 인증서/private key, 실제 cut/asset, 운영 설정이 Git 변경에 포함되지 않았다.

검증 후 결과를 [`SCENE_EQUIVALENCE.md`](SCENE_EQUIVALENCE.md)의 해당 행과 [`LEGACY_FEATURE_AUDIT.md`](LEGACY_FEATURE_AUDIT.md)의 관련 항목에 기록한다. 일부 scene 성공이나 과거 runner 성공으로 나머지 행을 일괄 완료 처리하지 않는다.
