CHAT_OUTPUT_BEGIN
COMMAND_ID=NEWFI-260907-A-PORTAL200-APPLY4DESIGN5
STATUS=OK
RC=0
HOST=pve01
MODE=read-only
COMPONENT=newfi-portal200-apply4-local-carrier-recovery5
REFERENCE_REGISTER_CHECK=OK
REFERENCE_SHA256=5a3d8e5154c41cb582a4e0aca68090be1f0138918bf82131a948df326f9d8d66
ERROR_REGISTER_CHECK=OK
ERROR_REGISTER_SHA256=3b09a553ec0f527ed3afeed4753f52a74ee3036045bbfb3c685e9f8af4ba7ba0
COMMAND_SHA256=d8972287e5a04bda3fc17acba988b2651b92ce4193aa3038f19cf27372093f65
DUPLICATE_FAILED_COMMAND_BLOCKED=false
EXECUTION_STARTED=true
CHANGE_DECLARED=false
RESULT_CONTRACT_VALID=true
RESULT_CONTRACT_STATUS=NOT_APPLICABLE
RESULT_CONTRACT_ERROR=NONE
COMMAND_RC=0
CHANGES_MADE=false
ROLLBACK_STARTED=false
ROLLBACK_RESTORED=null
MUTATION_OUTCOME=NO_MUTATION
SANITIZED=yes
SECRETS_INCLUDED=no
PRIVATE_ADDRESSES_INCLUDED=no
RAW_EVIDENCE_SHA256=58a32ed77e16ff3fbab7bcfac43e592c6e2daf2a7cf6208e7a229a258b6e3a3f
SANITIZED_OUTPUT_SHA256=58a32ed77e16ff3fbab7bcfac43e592c6e2daf2a7cf6208e7a229a258b6e3a3f
OUTPUT_BEGIN
LOCAL_APPLY3_CARRIER_RECOVERY5_BEGIN=true
COMMAND_ID=NEWFI-260907-A-PORTAL200-APPLY4DESIGN5
MUTATION_SCOPE=NONE_READ_ONLY
LOCAL_MUTATIONS_PERFORMED=NO
TARGET_OLD_COMMAND_ID=NEWFI-260907-PORTAL200-APPLY3
SEED_COUNT=41
SEED_BASENAMES=NEWFI.txt,NEWFI-260907-A-PORTAL200-APPLY4PREP1.stdout.txt,NEWFI-260907-B-PORTAL200-RUNNERCONTRACT2_REMOTE.txt,NEWFI-260907-B-PORTAL200-RUNNERCONTRACT2_STDOUT.txt,NEWFI-260907-B-PORTAL200-RUNNERCONTRACT4_REMOTE.txt,NEWFI-260907-B-PORTAL200-RUNNERCONTRACT4_STDOUT.txt,NEWFI-260907-B-PORTAL200-RUNNERCONTRACT5_REMOTE.txt,NEWFI-260907-B-PORTAL200-RUNNERCONTRACT5_STDOUT.txt,newfi-38-portal200-apply.cmd,newfi-39-portal200-rca1.cmd,newfi-40-plink-portal200-rca.cmd,newfi-41-plink-portal200-rca3.cmd,newfi-45-pageant-portal200-rca4.cmd,newfi-46-portal200-http-rca.cmd,newfi-48-portal200-apply2.cmd,newfi-49-portal200-preflight-rca.cmd,newfi-50-portal200-preflight-tail-rca.cmd,newfi-51-portal200-prod-http-network-rca.cmd,newfi-52-prod-public-http-rca.cmd,newfi-53-public-css-regex-rca.cmd,newfi-54-portal200-apply3.cmd,newfi-55-portal200-apply3-rca.cmd,newfi-56-portal200-apply3-failure-contract-rca.cmd,newfi-57-portal200-contract-rule-rca.cmd,newfi-58-portal200-exact-contract-rca.cmd,newfi-59-portal200-contract-ast-rca.cmd,newfi-60-portal200-import-contract-discovery.cmd,newfi-61-portal200-import-compact-discovery.cmd,newfi-62-xf-addon-rebuild-help.cmd,NEWFI45_PORTAL200_RCA4_REMOTE.txt,NEWFI45_PORTAL200_RCA4_REPORT.txt,NEWFI46_PORTAL200_HTTP_RCA_REMOTE.txt,NEWFI46_PORTAL200_HTTP_RCA_REPORT.txt,NEWFI46_PORTAL200_HTTP_RCA_STDOUT.txt,NEWFI48_PORTAL200_APPLY2_REMOTE.txt,NEWFI54_PORTAL200_APPLY3_REMOTE.txt,NEWFI55_REMOTE.txt,NEWFI55_STDOUT.txt,NEWFI56_REMOTE.txt,NEWFI56_STDOUT.txt,NEWFI_PARALLEL_FULL_HANDOFF_2026-09-07.txt
SEED_SHA256=869d6fdf2d4ac222d9ab4b83242563676f201e6ab21b84360fa73fc7f9bf7195,240d0fad482deb05c93289edd7ac58a84e34b98fc620d1e924b561aace8630bb,92255a714b86966ab5a15a86d18c6257c6c131b20520a6d2d2443b8fe969e93a,9bffe2728a05e52f03ec52fddfcce229588b6f6cff59e8932d6c8b5ae97c592b,e92d1a64266be86cc8e5441ff9f51e2161e35e00ffd12dce1eb049a4b6437dd0,53c8ea2dc91c33b2872da3974ac5a682cbdc907c9d232a29921b674d490d474e,0b4ba8d2b1eef91c627884071bbcdeea8ad9b4f6c04c8650e44eb468253d15f6,a59a4b7a482a0a39a51e08553749c1f15bcf74d4ba449f6d50caf7a6ba60f9b5,8481b33b162a78f0e1398b69fda50f581ace72f87cdfb2a16fb66ceb67fa4987,34245e3a7c02145e7f11e3c7919385ff955e739a5b278ede66f8ec91499a31e4,a398e89eec09cca84a907876dfe46cf491d8b61c402c1dc4e4ec36eb92167845,11ea567ce0485bd87c5da8292c4d01d55e741a85b75fe0bf5073ca33133055f7,96b2de593d982ef7a5cd09c5618e6aabaac259f896a70cc8f1b476b0c3b16a58,2ce247a8dae973819605d246273b294bf23ce1d118c3e8b92517696caff927c9,f0ad32266492990e58a90991f2d6ae808c3e57c1ad53244bfcd26100e163cd73,2d5a55fbb00e63a280680b312d3eff3e28d524e29e8acf9ee8cae77b7c7d1f2e,fae07cfd5e9196871cd8bec8305262c95ecd09654994675e9a4ac4617d761f99,9aca123af4a9674c2bc10f308a65b622dce616cac17fbfe9b58db3782fbd2d0e,c8e9abf642daba2118f3419a00ceaeb0a0cc9a54ae75575e5e903a8db8fd1c1d,92b919e1b4e0b910da5b8357619d853c5791dc73fb8a70d097563128d7c818e7,573c5c94fe59ed072e094281668ce9847dc4a9882019a2b89d8ba5f190d92176,6c98cb81713e91d9866d264e59c513b8a968c4f52bfbad55450957524600bb10,69d7a46640fd020c0daab39c3195ca77ff777242c22b419f66dfcb69be3a8a23,1f99ab8666c4d51844b8e95bf3768cb1b017921824e9ba8857a6579dfaafb6af,e10dcbd211b75626199ed50dda79f34ae169caef24ba747c74b9c79ddaab509e,3621712ba5fecff93abca54c601f4f176d976a67344a506a92d6de79918f05a5,07d2b88b41b30e90058fa1d389cc52d1ea3b20b557a962f9004db720bfc8d2b0,88b9f92828c1ee19537526a1eea93a52c59bc2f32a7034e45508e53cca2e3667,c8827d2f8b0ddc34771c1c71ffbf517d1bd117dcf36b4d3e5439ae262bc86c32,7fc78755edbeee8c49d3d47e26a723c01ee0e1e6b022b8fd2f70ccacdf8f4338,edba9db8c9ddedb0fe3e727c7ed5a255d1da6de82da722431f3db053abff2f38,3ea9e7e83411127caf13af674570f0ef9ac968b13393b0a173ce1344d6a29cf0,1c91f522bb94d4ce292bb451dd847f394362c0a8f4745ae8823a5eed33e2a10b,cfd23052413cad9d435f9aba290956e93d9191312d568cd1cc69437fc602ad03,474b251535372a61e8be1acf215c1cc2240f8414bdd1c5e03309fd5faa2f8f99,71da2a66224075527d8aa04c1281fdb7a2b9f078d76c349fc05b48a4b803a4c3,f4600697557109030fc188376f1407fc02e4ec2eff2c635d81e2438f8a325fe3,65ff188b6b7bd06f59feca719f4fad6e07ff8304ef157425b162b3928f674d31,5f2eb373b3b37d29dff72b8b7d5cc1346283f7b2de02ae2eb95a229e542c5468,b2968ec8823fa4bb7b3d6fd4a25f25d6a486eb9740ecc6ef362d1b31e5f439b2,869d6fdf2d4ac222d9ab4b83242563676f201e6ab21b84360fa73fc7f9bf7195
DECODED_LAYER_COUNT=64
STRONG_LAYER_COUNT=3
SOURCE_LAYER_COUNT=9
CHOSEN_LAYER_COUNT=9
CANDIDATE_LAYER_1_SOURCE=file:NEWFI.txt
CANDIDATE_LAYER_1_SHA256=869d6fdf2d4ac222d9ab4b83242563676f201e6ab21b84360fa73fc7f9bf7195
CANDIDATE_LAYER_1_BYTES=57343
CANDIDATE_LAYER_1_TEXT_BEGIN

ПОЛНЫЙ ПАРАЛЛЕЛЬНЫЙ HANDOFF: NEWFI / PORTAL200
Дата сборки: 2026-09-07
Назначение: начать работу сразу в двух чатах без повторного восстановления контекста.

===============================================================================
0. КРИТИЧЕСКОЕ РАЗДЕЛЕНИЕ ПРОЕКТОВ
===============================================================================

Приложенный handoff второго чата относится к KINGOFWOLK CSP.

KINGOFWOLK:
- agreed safe CSP stage полностью закрыт;
- shared counter = 0;
- final accepted checkpoint = KOW-260907-A-FINAL1;
- его product-state НЕ переносить в NEWFI.

Из KINGOFWOLK для NEWFI переиспользуются только проверенные operational rules:
- true-parallel read-only direct bridge;
- один writer для любых изменений;
- Pageant -> Plink -> pve01 transport;
- authority checks;
- homelab-chat-run contracts;
- immutable Gitea readback;
- unique COMMAND_ID;
- fail-closed drift handling;
- destructive Git / raw SQL mutation prohibition.

Полный KINGOFWOLK handoff приложен ниже как APPENDIX A.

===============================================================================
1. АКТИВНЫЙ ПРОЕКТ
===============================================================================

Проект: NEWFI.
Активный участок: Portal200.

Сейчас НЕ продолжаем KingOfWolk и НЕ занимаемся Linux.

Текущая рабочая проблема:
предыдущая change-команда Portal200 APPLY3 не является accepted checkpoint.
После неё выполнены read-only RCA/discovery шаги.

===============================================================================
2. AUTHORITATIVE GITEA RUNTIME
===============================================================================

Runtime repo:
homelab-admin/homelab-public-context

Branch:
assistant-runtime

Authoritative exact readback:
runtime/history/<COMMAND_ID>.txt

Не полагаться на generic latest без точного COMMAND_ID.

Ключевые текущие NEWFI checkpoints:

A) NEWFI-260907-PORTAL200-APPLY3

STATUS=FAIL
RC=76
HOST=pve01
MODE=change
COMPONENT=newfi-portal200-deploy

Критично:
RESULT_CONTRACT_VALID=false
RESULT_CONTRACT_STATUS=UNKNOWN
RESULT_CONTRACT_ERROR=INVALID_CONTRACT:ValueError
COMMAND_RC=1
CHANGES_MADE=unknown
ROLLBACK_STARTED=unknown
ROLLBACK_RESTORED=null
MUTATION_OUTCOME=UNKNOWN

Следствие:
APPLY3 НЕЛЬЗЯ считать ни успешным deploy, ни доказанным rollback.
Не повторять этот COMMAND_ID.
Не строить следующий change на предположении, что production/staging точно остались
в старом состоянии.

B) NEWFI-260907-PORTAL200-CONTRACTAST1

STATUS=OK
RC=0
MODE=read-only
COMPONENT=newfi-portal200-contract-ast-rca
CHANGES_MADE=false
MUTATION_OUTCOME=NO_MUTATION

Назначение:
read-only RCA runner result-contract / AST после APPLY3.

Это accepted diagnostic checkpoint.

C) NEWFI-260907-PORTAL200-IMPORTDISC2

STATUS=OK
RC=0
MODE=read-only
COMPONENT=newfi-portal200-import-compact-discovery
CHANGES_MADE=false
MUTATION_OUTCOME=NO_MUTATION

Назначение:
compact read-only discovery import path + runner-contract requirements.

Из опубликованного evidence видно, что runner contract жёстко валидирует как минимум:
- поддерживаемую version;
- command_id exact match;
- status только OK / BLOCKED / FAIL;
- changes_made обязан быть boolean.

При APPLY4 не импровизировать contract JSON:
сначала перечитать CONTRACTAST1/IMPORTDISC2 и runner source.

D) NEWFI-260907-PORTAL200-REBUILDHELP1

STATUS=OK
RC=0
MODE=read-only
COMPONENT=newfi-portal200-rebuild-help
CHANGES_MADE=false
MUTATION_OUTCOME=NO_MUTATION

Ключевой вывод:

XenForo CLI поддерживает штатный:
xf:addon-rebuild [options] [--] <id>

Arguments:
id = Add-On ID

Option:
-f, --force
Skip verifying that the add-on is rebuildable

Следствие:
старый ручной PHP/import loop не должен быть механизмом следующего deploy.
Следующий candidate должен опираться на штатный XenForo add-on rebuild,
НО exact Add-On ID сначала должен быть подтверждён read-only evidence.
Не hardcode Newfi/Portal только по памяти.

===============================================================================
3. ГЛАВНЫЙ ВЫВОД ПО APPLY3
===============================================================================

APPLY3 имеет ДВЕ независимые проблемы, которые нельзя смешивать:

1. RUNNER / PUBLICATION CONTRACT FAILURE
   Runner отклонил result contract:
   INVALID_CONTRACT:ValueError.

2. PRODUCT MUTATION STATE НЕ ДОКАЗАН
   Outer runtime показывает:
   CHANGES_MADE=unknown
   ROLLBACK_STARTED=unknown
   MUTATION_OUTCOME=UNKNOWN

Поэтому правильная последовательность сейчас:

НЕ:
"сразу повторить deploy как APPLY4"

А:
- параллельно восстановить current prestate;
- параллельно построить корректный APPLY4 design;
- change запускать только после read-only PASS prestate.

===============================================================================
4. ПАРАЛЛЕЛЬНАЯ МОДЕЛЬ ДЛЯ NEWFI
===============================================================================

Использовать проверенную KOW concurrency model.

LANE A = SINGLE WRITER / DEPLOY DESIGN
LANE B = TRUE PARALLEL READ-ONLY AUDITOR

Разрешено одновременно:
- Lane A читает Git/Gitea/reference и строит новый carrier;
- Lane B выполняет direct read-only audit;
- оба читают immutable Gitea history.

Запрещено:
- два change одновременно;
- два git commit/push writer;
- два application/XenForo mutation path;
- повтор failed COMMAND_ID;
- Lane B mutation.

На момент фактического APPLY4:
Lane A — единственный writer.
Lane B не должен запускать новый state-sensitive snapshot в середине mutation,
кроме специально спроектированного concurrent monitor с drift guards.

===============================================================================
5. COMMAND_ID FAMILY ДЛЯ НОВОЙ ПАРАЛЛЕЛЬНОЙ СЕССИИ
===============================================================================

Чтобы не путать старую историю:

Lane A:
NEWFI-260907-A-PORTAL200-...

Lane B:
NEWFI-260907-B-PORTAL200-...

Каждый attempt:
новый COMMAND_ID.

Не reuse:
NEWFI-260907-PORTAL200-APPLY3
или любой другой failed ID.

===============================================================================
6. LANE B — ПЕРВАЯ ЗАДАЧА, КОТОРУЮ МОЖНО ЗАПУСКАТЬ СРАЗУ
===============================================================================

Цель:
POST-APPLY3 CURRENT-STATE RECOVERY AUDIT.

Только read-only.

Lane B должен доказать:

1. Authority:
   /etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md
   /etc/pve/31_HOMELAB_REFERENCE.md
   readable + nonempty + прочитаны.

2. Runner:
   current /usr/local/sbin/homelab-chat-run SHA;
   exact result-contract schema;
   duplicate command semantics.

3. Git:
   authoritative NEWFI repo/worktree;
   branch;
   local HEAD;
   remote HEAD;
   clean/dirty;
   relevant Portal200 files + hashes;
   no unknown local mutation.

4. XenForo environment:
   exact production/staging VM/CT IDs;
   exact docroots;
   exact XenForo versions;
   service health;
   current addon list.

5. Exact Portal Add-On ID:
   resolve from add-on metadata/live XF state.
   Do not mutate.
   Candidate methods:
   - xf:addon-list;
   - addon.json;
   - xf_addon read-only SELECT;
   - existing deployment metadata.

6. Current Portal add-on state:
   installed/active/version;
   pending rebuild/install state if observable read-only;
   relevant cache/code-cache presence;
   current route behavior.

7. APPLY3 aftermath:
   whether product files differ from accepted pre-APPLY3 baseline;
   whether DB/install state changed;
   whether staging changed;
   whether production changed;
   whether rollback evidence exists.

8. Health:
   Nginx / PHP-FPM / MariaDB / related app services;
   HTTP codes for important NEWFI routes.

9. DB:
   SELECT only.
   No UPDATE/INSERT/DELETE/ALTER.

10. Final decision:
   ELIGIBLE_FOR_PORTAL200_APPLY4_PREP
   or exact HOLD_...

Lane B output should be bounded and sanitized.
No secrets/private credentials.

===============================================================================
7. LANE A — ПЕРВАЯ ЗАДАЧА, КОТОРУЮ МОЖНО ДЕЛАТЬ ПАРАЛЛЕЛЬНО
===============================================================================

Lane A НЕ должен ждать Lane B, чтобы начать design.

Он может сразу:

1. перечитать:
   APPLY3
   CONTRACTAST1
   IMPORTDISC2
   REBUILDHELP1

2. перечитать:
   /etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md
   /etc/pve/31_HOMELAB_REFERENCE.md

3. найти authoritative NEWFI Git worktree / branch / Portal200 deployment source.

4. построить APPLY4 carrier OFFLINE / PREP ONLY.

5. В APPLY4 заранее исправить:
   - result contract;
   - official XenForo rebuild path;
   - fail-closed prestate;
   - explicit rollback semantics;
   - exact mutation accounting;
   - exact COMMAND_ID;
   - publication/readback.

6. Не запускать APPLY4 mutation, пока Lane B не подтвердил current-state eligibility.

===============================================================================
8. APPLY4 — ЧТО ДОЛЖНО БЫТЬ В ДИЗАЙНЕ
===============================================================================

APPLY4 не должен быть слепым rerun APPLY3.

Обязательные свойства:

A. PRESTATE
- authority checks;
- runner SHA;
- exact Git local/remote/clean;
- exact expected source bytes/hashes;
- exact current Portal add-on state;
- services healthy;
- target environment identity;
- no unexplained post-APPLY3 drift.

B. ADD-ON IMPORT / REBUILD
- use native XenForo command:
  xf:addon-rebuild <CONFIRMED_ADDON_ID>
- use -f only if evidence/design shows it is needed;
- do not return to manual PHP import loop.

C. RESULT CONTRACT
Must match runner schema exactly.
At minimum:
- supported version;
- exact command_id;
- status in OK/BLOCKED/FAIL;
- changes_made boolean;
- rollback fields with correct types.
Do not emit unknown where boolean is required.

D. FAIL CLOSED
If prestate is not exact:
BLOCKED / no mutation.

E. ROLLBACK
Before mutation:
- prove rollback target/material exists;
- preserve previous product/app state;
- rollback on any failed verification;
- verify rollback result.

F. VERIFICATION
After candidate:
- XenForo CLI exit;
- add-on version/state;
- relevant DB rows read-only;
- routes;
- services;
- XenForo error log;
- no unexpected file drift.

G. GIT
If Git promotion belongs in this Portal200 stage:
- one writer;
- explicit path staging;
- git diff --check;
- no git add .;
- no reset --hard;
- no git clean -fd;
- no force push;
- verify local == remote after push.

H. PUBLICATION
Exact runtime/history/<CID>.txt must be readable and match COMMAND_ID.

===============================================================================
9. ЧТО НЕЛЬЗЯ ДЕЛАТЬ
===============================================================================

- Не считать APPLY3 успешным.
- Не считать APPLY3 rollback успешным.
- Не reuse APPLY3 ID.
- Не запускать raw SQL mutation.
- Не запускать manual PHP import loop, пока native rebuild доказан.
- Не hardcode add-on ID без live/source evidence.
- Не делать git reset --hard.
- Не делать git clean -fd.
- Не force push.
- Не запускать два writer.
- Не принимать generic latest как authoritative без exact COMMAND_ID.
- Не использовать KOW VM/IP/path как NEWFI environment.
- Не переносить KOW product state в NEWFI.

===============================================================================
10. WINDOWS / SSH TRANSPORT — ПЕРЕИСПОЛЬЗУЕМ ИЗ KOW
===============================================================================

Plink:
C:\Program Files\PuTTY\plink.exe

Pageant:
C:\Program Files\PuTTY\pageant.exe

SSH key:
<USERPROFILE>\Documents\ssh\homelab_ed25519.ppk

Encrypted PPK.
<REDACTED_SENSITIVE_LINE>

Normal transport:

CMD
 -> PowerShell 5.1
 -> Start-Process
 -> Plink -batch -agent -ssh -P 22 -m <remotePath>
 -> root@<PRIVATE_IPV4>
 -> pve01
 -> direct read-only bridge OR homelab-chat-run
 -> Gitea runtime publication
 -> exact immutable readback

Do not use:
- MobaXterm;
- -i in normal carrier;
- pause;
- Notepad;
- blind host-key acceptance.

PowerShell:
separate stdout/stderr;
-NoNewWindow;
-Wait;
-PassThru;
real ExitCode.

Remote command file:
UTF-8 no BOM;
one physical line;
SHA verified.

Large payload:
zlib + base64;
SHA256;
python3 -I -B.

===============================================================================
11. AUTHORITY CHECKS
===============================================================================

Перед серьёзной server command:

/etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md
/etc/pve/31_HOMELAB_REFERENCE.md

Оба:
readable;
nonempty;
прочитать.

Visibly emit:

ERROR_REGISTER_CHECK=OK
REFERENCE_CHECK=OK
AUTHORITY_CHECK_SCOPE=READABILITY_ONLY_INCIDENTS_NOT_CLOSED

Fail -> STOP.

===============================================================================
12. GITEA READBACK
===============================================================================

Authoritative:

https://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/<COMMAND_ID>.txt

JSON, если нужен:

https://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/<COMMAND_ID>.json

Нельзя делать вывод по runtime/latest.txt,
если COMMAND_ID не совпадает.

===============================================================================
13. КАК СИНХРОНИЗИРОВАТЬ ДВА ЧАТА
===============================================================================

После каждого accepted checkpoint:

1. чат публикует exact COMMAND_ID;
2. второй чат читает immutable Gitea history;
3. оба обновляют общий current-state;
4. FAIL/RCA не считать product progress;
5. PASS read-only может закрывать audit/preflight;
6. mutation checkpoint только Lane A.

Если Lane A сделал change:
Lane B перед следующим audit обязан заново прочитать этот exact checkpoint.

Если Lane B обнаружил HOLD:
Lane A не запускает mutation до разбора HOLD.

===============================================================================
14. PROMPT ДЛЯ ЧАТА A — SINGLE WRITER
===============================================================================

Ты LANE A / SINGLE WRITER для NEWFI Portal200.

Используй Gitea assistant-runtime как machine truth.
Не занимайся KingOfWolk: KOW CSP agreed stage уже CLOSED.

Current NEWFI checkpoints:
- APPLY3 FAIL, invalid result contract, mutation outcome UNKNOWN.
- CONTRACTAST1 OK read-only.
- IMPORTDISC2 OK read-only.
- REBUILDHELP1 OK read-only.
- native XenForo command xf:addon-rebuild [options] <id> подтверждён.

Твоя задача:
параллельно с Lane B спроектировать NEWFI Portal200 APPLY4,
но НЕ запускать mutation, пока Lane B не вернёт
ELIGIBLE_FOR_PORTAL200_APPLY4_PREP.

APPLY4:
- новый unique COMMAND_ID family NEWFI-260907-A-PORTAL200-...
- exact authority checks;
- current-state preconditions;
- correct homelab-chat-run result contract;
- native xf:addon-rebuild with exact live-confirmed add-on ID;
- fail closed;
- rollback proof;
- verification;
- single writer;
- exact Gitea publication/readback.

Перед каждой реальной mutation заново прочитай последние Lane A + Lane B checkpoints.

===============================================================================
15. PROMPT ДЛЯ ЧАТА B — READ-ONLY AUDITOR
===============================================================================

Ты LANE B / STRICT READ-ONLY AUDITOR для NEWFI Portal200.

Никаких Git/application/DB/production mutations.

Используй proven bridge:
direct read-only phase outside homelab-chat-run;
serialized sanitized publication through runner.

COMMAND_ID family:
NEWFI-260907-B-PORTAL200-...

Сразу выполни post-APPLY3 current-state recovery audit:

- authority checks;
- runner SHA + contract;
- authoritative Git local/remote/clean;
- exact NEWFI staging/prod identity;
- exact XenForo docroot/version;
- exact Portal Add-On ID;
- installed/version/rebuildable state;
- APPLY3 aftermath;
- file/source hashes;
- service health;
- relevant HTTP routes;
- XF error log state;
- read-only DB evidence;
- no mutation.

Final decision:
ELIGIBLE_FOR_PORTAL200_APPLY4_PREP
or exact HOLD_...

Публикуй bounded sanitized evidence в immutable Gitea history.
После публикации сообщи exact COMMAND_ID.

===============================================================================
16. РЕКОМЕНДУЕМЫЙ СТАРТ СЕЙЧАС
===============================================================================

Лучший быстрый порядок:

NOW:
- текущий чат = Lane A;
- новый второй чат = Lane B.

Одновременно:
Lane A готовит APPLY4 design.
Lane B снимает current-state recovery audit.

Когда Lane B PASS:
Lane A сверяет immutable evidence и запускает единственный change APPLY4.

После APPLY4:
Lane B выполняет независимый read-only post-change verification,
если это реально добавляет evidence и не дублирует уже встроенный rollback/canary.

===============================================================================
17. COUNTER / ROADMAP
===============================================================================

Не использовать KingOfWolk counter.
У KOW counter = 0 и scope закрыт.

Для NEWFI ранее использовалась рабочая оценка оставшихся команд,
но её нельзя считать authoritative runtime state без восстановления NEWFI roadmap.

Первый Lane B recovery audit должен также найти/проверить текущую NEWFI roadmap/
accepted-stage count, если он хранится в repo/reference.

До этого не печатать ложный точный countdown.

===============================================================================
18. SOURCE-OF-TRUTH PRIORITY
===============================================================================

При конфликте:

1. /etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md
2. /etc/pve/31_HOMELAB_REFERENCE.md
3. exact immutable Gitea runtime/history/<CID>.txt
4. authoritative Git source/current live state
5. этот handoff

Этот handoff ускоряет старт, но не перекрывает более свежий live/Gitea evidence.

===============================================================================
APPENDIX A
ПОЛНЫЙ HANDOFF ИЗ ВТОРОГО ЧАТА: KINGOFWOLK CSP
(сохранён целиком; используется для operational/concurrency rules, не как NEWFI product-state)
===============================================================================


ПОЛНЫЙ HANDOFF: KINGOFWOLK CSP
Дата контекста: 2026-09-07

1. СТАТУС ПРОЕКТА НА МОМЕНТ HANDOFF

Проект: KingOfWolk forum CSP.

Shared counter:
0 команд до полного финиша.

Важно: 0 означает, что согласованный безопасный CSP-stage полностью завершён и подтверждён evidence. Это не означает, что CSP доведён до теоретически максимальной жёсткости.

Отдельно оставлены будущие hardening-задачи:
- удаление style-src 'unsafe-inline';
- рефакторинг существующего javascript: URL;
- перевод script-src-elem из Report-Only в enforcing после достаточного периода наблюдения.

Финальный accepted checkpoint:

KOW-260907-A-FINAL1
STATUS=OK
RC=0
DECISION=KINGOFWOLK_CSP_AGREED_STAGE_FINAL_EVIDENCE_CLOSED

Финальный canary не внёс изменений в production.


2. CANONICAL KINGOFWOLK ENVIRONMENT

Форум:

Domain:
https://kingofwolk.ru

Forum VM:
VM160
host: pve02
IP: <PRIVATE_IPV4>

XenForo:
2.3.12
XF id: 2031270

Database:
xf_kingofwolk

Document root:
/var/www/forums/kingofwolk/public

XF source:
/var/www/forums/kingofwolk/src/XF

Из ранее принятого baseline:

boardActive = 1
board title = KingOfWolk
style id = 2
style name = KingOfWolk Dark

CAPTCHA = Cloudflare Turnstile
friendly URLs = enabled
third-party add-ons = none

users = 2
admins = 1
nodes = 13
threads = 73
posts = 73
attachments = 0
XF errors = 0

Origin-side services были healthy:

Nginx
MariaDB
php8.3-fpm

CSP реализуется на edge, а не origin XenForo.


3. EDGE / NPMPLUS

Edge находится на:

VM130
host: pve03

NPMplus:

container: npmplus
image: zoeyvid/npmplus:2026-06-17-b1

Live KingOfWolk proxy config:

/opt/npmplus/nginx/proxy_host/996.conf

Metadata:

owner: root:root
mode: 0644

CSP collector log:

/opt/npmplus/csp-reports/kingofwolk-csp-report.log

Log metadata:

root:root
0600

Directory:

0700

Collector endpoint:

/__csp-report-v1

Semantics:

GET  -> 405
Allow -> POST

Collector поддерживает:

application/csp-report
application/reports+json
application/json

Collector sanitizes:
- script-sample;
- query/fragment у URI.

Rotation threshold:

>= 5 MiB

Финальный canary снова подтвердил:

collector GET = 405
Allow = POST


4. GIT — AUTHORITATIVE SOURCE

Используемый repo:

homelab-admin/homelab-ops

Изолированный CSP worktree:

/root/kingofwolk-csp-baseline-20260907

Branch:

forum/kingofwolk-csp-baseline-20260907

Не использовать /srv/homelab-ops для изменений KingOfWolk CSP.
Исторически он был dirty/diverged и использовался только как reference.

Clone-local Git identity:

user.name  = homelab-ops-bootstrap
user.email = root@pve01.gram1.ru

Текущий финальный accepted HEAD:

6f584c5faa76d0067b08977aeb583fa0e4de14aa

Parent:

3e5d8584ae6887c292cc910c5796e73d8d20aecd

Commit message:

csp(kingofwolk): stage script nonce and enforce font media

Текущий source file:

deploy/forums/kingofwolk/edge/996.conf

Текущий source SHA / production edge SHA:

458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e

Финальный canary подтвердил:

Git local HEAD  = 6f584c5faa76d0067b08977aeb583fa0e4de14aa
Git remote HEAD = 6f584c5faa76d0067b08977aeb583fa0e4de14aa
worktree clean  = true

И edge до/после финального canary оставался:

458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e


5. ИСТОРИЯ ACCEPTED GIT CSP COMMITS

Последовательность основных accepted commits:

6908d4ebf2552244a68e3c0990fdc3a219c05d2c
initial CSP baseline docs

f8d1ae6981df35f25d09a41392bc0a26742b7f38
JoyPixels Report-Only allowance

34f9c76c5cbf81f9cdf7f50d1ac501cf870abc9c
minimal structural enforcement source

c7a8a68a74fd3eb74cfb708b6bd3b866159b4899
csp(kingofwolk): enforce same-origin form submissions

Его source SHA:

834973af57263b326ca6aee52b2ecb3fa2cf0fb486e5a77a62d4210395b8d9c3

Следующий:

e4635c9997801a0ea65aad727270c88ee3bdb817
csp(kingofwolk): enforce PWA self sources

Source SHA:

28e855ff56c4844768f0e8606fe0edc2acb3f548a5c06fa15c0d80ae00e2bc1b

Следующий:

3e5d8584ae6887c292cc910c5796e73d8d20aecd
csp(kingofwolk): enforce image and network sources

Source SHA:

fc9fb1dbdb6141bfb43a8122dcf3db435a94da48d0a0c02fe9dfae019b1439c5

Финальный текущий commit:

6f584c5faa76d0067b08977aeb583fa0e4de14aa
csp(kingofwolk): stage script nonce and enforce font media

Source SHA:

458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e


6. ТЕКУЩИЙ PRODUCTION ENFORCING CSP

Финальный enforcing CSP:

base-uri 'self';
object-src 'none';
frame-ancestors 'self';
form-action 'self';
worker-src 'self';
manifest-src 'self';
img-src 'self' data: blob: https://cdn.jsdelivr.net/joypixels/assets/8.0/png/unicode/64/;
connect-src 'self' https://challenges.cloudflare.com;
frame-src 'self' https://challenges.cloudflare.com;
font-src 'self' data:;
media-src 'self';

В одной строке:

base-uri 'self'; object-src 'none'; frame-ancestors 'self'; form-action 'self'; worker-src 'self'; manifest-src 'self'; img-src 'self' data: blob: https://cdn.jsdelivr.net/joypixels/assets/8.0/png/unicode/64/; connect-src 'self' https://challenges.cloudflare.com; frame-src 'self' https://challenges.cloudflare.com; font-src 'self' data:; media-src 'self';

Что намеренно НЕ enforcing:

default-src
script-src
script-src-elem
script-src-attr
style-src
style-src-elem
style-src-attr

Также enforcing policy не содержит:

'unsafe-inline'
'unsafe-eval'

Это намеренно: script/style hardening ещё не переведён в production enforcement.


7. ТЕКУЩИЙ PRODUCTION REPORT-ONLY CSP

Source-template на edge содержит Nginx variable $request_id.

Логическая policy:

default-src 'self';
base-uri 'self';
object-src 'none';
frame-ancestors 'self';
form-action 'self';

script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com;

script-src-elem
    'self'
    'nonce-$request_id'
    https://challenges.cloudflare.com;

style-src 'self' 'unsafe-inline';

img-src
    'self'
    data:
    blob:
    https://cdn.jsdelivr.net/joypixels/assets/8.0/png/unicode/64/;

font-src 'self' data:;

connect-src
    'self'
    https://challenges.cloudflare.com;

frame-src
    'self'
    https://challenges.cloudflare.com;

worker-src 'self';
manifest-src 'self';
media-src 'self';

report-uri /__csp-report-v1;

Точная source-строка:

default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; form-action 'self'; script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com; script-src-elem 'self' 'nonce-$request_id' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://cdn.jsdelivr.net/joypixels/assets/8.0/png/unicode/64/; font-src 'self' data:; connect-src 'self' https://challenges.cloudflare.com; frame-src 'self' https://challenges.cloudflare.com; worker-src 'self'; manifest-src 'self'; media-src 'self'; report-uri /__csp-report-v1;

При HTTP response $request_id уже заменён Nginx на фактический per-request nonce.


8. NGINX NONCE TRANSPORT

Текущий source также содержит transport для nonce-инъекции.

Ключевые строки:

proxy_set_header Accept-Encoding "";
sub_filter_once off;
sub_filter '<script' '<script nonce="$request_id"';

Зачем отключено upstream compression:

sub_filter должен видеть HTML body в несжатом виде.

До production promotion был отдельно доказан forwarded-origin request:

HTTP = 200
Content-Encoding = NONE

Nonce source:

$request_id

В Report-Only header используется:

'nonce-$request_id'

В HTML тот же $request_id инъектируется в каждый <script> tag.


9. ФИНАЛЬНЫЙ NONCE EVIDENCE

Финальный checkpoint:

KOW-260907-A-FINAL1

проверил HTML маршруты дважды:

/
 /login/
 /register/
 /help/smilies/
 /threads/dobro-pozhalovat-na-ofitsial-nyi-forum-kingofwolk.1/

Для каждого запроса проверялось:

HTTP = 200
ровно один nonce в CSP Report-Only
nonce не содержит literal $
nonce имеет допустимый формат
каждый <script> имеет ровно один nonce
nonce каждого <script> == nonce из response CSP header
raw nonce count == parsed script count

Дополнительно:

nonce не должен повторяться между двумя запросами одного route
nonce не должен совпадать ни с одним nonce других canary requests

Финальный результат:

KOWAFINAL1_NONCE_UNIQUE_ACROSS_ALL_REQUESTS=true
KOWAFINAL1_NONCE_VALUES_EXPORTED=NO

То есть сами nonce наружу не публиковались.


10. CSP REPORT LOG BASELINE

Принятый baseline:

SHA256:
2f7977cbfaed7953bd70033c0cf92138eedf974f5886fadf4b4971f2d00a4143

lines:
2

Исторически в нём:

line 1 = synthetic test
line 2 = historical real JoyPixels violation до allow-list

Этот baseline не менялся через:

- PWA stage;
- img/connect/frame stage;
- nonce design;
- font/media promotion;
- production Stage2;
- финальный browser/evidence canary.

Финальный результат:

KOWAFINAL1_NEW_CSP_REPORTS_AFTER_FINAL_BROWSER_CANARY=0

NPMplus:

before = healthy
after  = healthy


11. ДРУГИЕ SECURITY HEADERS

Они не менялись и финальный canary проверил их exact match.

X-Frame-Options:
SAMEORIGIN

X-Content-Type-Options:
nosniff

Referrer-Policy:
strict-origin-when-cross-origin

Strict-Transport-Security:
max-age=31536000

Permissions-Policy:
geolocation=(), microphone=(), camera=()

Ранее жёлтые browser console warnings были связаны с Permissions-Policy, а не с CSP.


12. JOYPIXELS

Разрешён только path-scoped источник:

https://cdn.jsdelivr.net/joypixels/assets/8.0/png/unicode/64/

Не разрешён весь:

https://cdn.jsdelivr.net

Ранее dedicated evidence:
- реальный historical CSP report указывал JoyPixels;
- /help/smilies/ реально использует этот path;
- bad JoyPixels path count = 0.

Финальный canary:

KOWAFINAL1_JOYPIXELS_RENDERED_REF_COUNT=32
KOWAFINAL1_JOYPIXELS_SAMPLE_HTTP=200


13. CLOUDFLARE TURNSTILE

Turnstile остаётся обязательной runtime зависимостью для регистрации.

Нужные разрешения сохранены:

script:
https://challenges.cloudflare.com

connect:
https://challenges.cloudflare.com

frame:
https://challenges.cloudflare.com

В текущем enforcing уже есть:

connect-src 'self' https://challenges.cloudflare.com;
frame-src 'self' https://challenges.cloudflare.com;

В Report-Only script policies также Cloudflare разрешён.

Критически важный урок:

не требовать статический <script src="https://challenges.cloudflare.com/..."> в исходном HTML.

Turnstile может быть динамически instantiated.

Именно из-за неправильного static-script требования ранее упал:

KOW-260907-A-ICF5

с:

TURNSTILE_SCRIPT_REF_MISSING

После исправления модели:

KOW-260907-A-ICF6

был accepted.

Финальный canary проверял наличие Turnstile/challenges runtime tokens на /register/, а не статический script tag.


14. FORM-ACTION

Этап полностью закрыт ранее.

Enforcing:

form-action 'self'

Перед promotion были проверены:

external public form origins = 0
payment profiles = 0
user upgrades = 0
provider/options related external forms = 0

Production apply был с rollback proof и browser/server canary.

Не пересматривать без новой фактической причины.


15. PWA DIRECTIVES

Полностью accepted:

worker-src 'self'
manifest-src 'self'

Закрыты checkpoints:

PWA3 audit
PWA4 preflight
PWA5 Git promotion
PWA6 production + rollback proof
PWA9 final recovery/readback

Исторический transport bug:

PWA7 browser canary сам по себе был PASS,
но outer publication сломался.

PWA8 RCA:
publisher ошибочно ожидал evidence path в sys.argv[1].

После этого правило:

publisher должен использовать literal evidence path, а не рассчитывать, что sys.argv[1] содержит путь.


16. IMG/CONNECT/FRAME STAGE

Полностью закрыт.

Текущие enforcing directives:

img-src 'self' data: blob: https://cdn.jsdelivr.net/joypixels/assets/8.0/png/unicode/64/;

connect-src 'self' https://challenges.cloudflare.com;

frame-src 'self' https://challenges.cloudflare.com;

Ключевые accepted checkpoints:

KOW-260907-A-ICF2
STATUS=OK
DECISION=ELIGIBLE_FOR_IMG_CONNECT_FRAME_SOURCE_PREP

Evidence:

CSS bytes scanned = 297724
stylesheets = 9
CSS url refs = 245
origins = DATA / SELF

Git promotion:

KOW-260907-A-ICF3

Commit:

3e5d8584ae6887c292cc910c5796e73d8d20aecd

Production apply:

KOW-260907-A-ICF4

Rollback proof passed.

Browser recovery:

KOW-260907-A-ICF6
DECISION=IMG_CONNECT_FRAME_ENFORCEMENT_POST_BROWSER_CANARY_CLEAN


17. INLINE SCRIPT/STYLE AUDIT — ИСХОДНЫЕ ВЫВОДЫ

До nonce stage:

rendered nonce attrs = 0
native nonce source signals = 0

KOW-260907-A-INLINE1 показал по 5 guest routes:

external_scripts = 35

inline executable scripts = 5
inline non-executable scripts = 3
inline script tags total = 8

style attributes = 31

javascript: URLs = 6 occurrences
distinct javascript: values later narrowed to 1

event-handler attrs = 0

script nonce attrs = 0
style nonce attrs = 0
style tags = 0

Основной decision:

HOLD_UNSAFE_INLINE_APPLICATION_NONCE_HASH_DESIGN_REQUIRED


18. ПОЧЕМУ СТАТИЧЕСКИЕ HASHES ДЛЯ INLINE SCRIPTS БЫЛИ ОТКЛОНЕНЫ

KOW-260907-A-INLINE2:

STATUS=OK
RC=0

DECISION=
APPLICATION_TEMPLATE_REFACTOR_DESIGN_REQUIRED

Было установлено:

EXEC_SCRIPTS_WITH_DYNAMIC_MARKERS=5
GLOBAL_DISTINCT_EXEC_SCRIPT_BODIES=5
GLOBAL_DISTINCT_STYLE_ATTR_VALUES=7
GLOBAL_DISTINCT_JAVASCRIPT_URLS=1
GLOBAL_DISTINCT_EVENT_HANDLER_VALUES=0

То есть все 5 executable inline scripts имеют динамические признаки.

Поэтому:
- хеширование уже rendered script bodies не использовать;
- не публиковать и не хешировать dynamic rendered inline values;
- nonce оказался более подходящим механизмом для <script> elements.


19. STYLE ATTRIBUTES

Было обнаружено:

31 style attributes
7 distinct style attribute values

Они оказались стабильными между guest renders и не сработал secretish gate.

Но removal style-src 'unsafe-inline' не включён в текущий завершённый scope.

Причина:

nonce работает для <style> / <script> elements, но не решает обычные style="" attributes.

Потенциальные будущие пути:
- template/application refactor style attrs;
- либо отдельный строго доказанный style-src-attr / 'unsafe-hashes' design.

На текущем production:

Report-Only:
style-src 'self' 'unsafe-inline';

Enforcing style-src отсутствует.


20. JAVASCRIPT: URLS

Исходно:

6 occurrences
1 distinct value
event handlers = 0

В процессе анализа было решено:

JAVASCRIPT_URL_DESIGN=
APPLICATION_REFACTOR_REQUIRED_NOT_HASH_ALLOWLIST

То есть javascript: URL не закрывается текущей CSP-stage через hash allowlist.

Было выполнено source mapping на VM160 по:

/var/www/forums/kingofwolk/internal_data/code_cache/templates
/var/www/forums/kingofwolk/src/XF
/var/www/forums/kingofwolk/public/js

Raw javascript: value наружу не публиковался.

Текущий статус:

deferred application refactor.


21. EDGE NONCE DESIGN SEQUENCE И HARNESS FAILURES

Во время разработки nonce-stage было несколько не product failures, которые важно не путать с production regressions.

KOW-260907-A-INLINE3

STATUS=FAIL
RC=23

Failed step:

origin_compression

Error:

origin_compression:RC_2

Причина:

probe зависел от Python внутри guest/runtime path

Product mutation:

NO


KOW-260907-A-INLINE4

STATUS=FAIL
RC=23

Failed step:

transform_and_hash_candidates

Error:

'str' object has no attribute 'decode'

Причина — чистый harness bug:
- transformed HTML уже был str;
- helper повторно вызывал .decode().

До падения подтвердилось:

Git clean
NPMplus healthy
nginx sub module compiled = true
nginx version = 1.31.2

proxy_pass count = 1
origin gzip = gzip
origin identity = NONE
HTTP = 200


KOW-260907-A-INLINE5

STATUS=FAIL
RC=23

Failed step:

nginx_nonce_syntax_probe_tempfile

Error:

EPHEMERAL_PROBE_FILE_NOT_CLEANED

Это был слишком строгий/неправильный cleanup harness.

Product mutation не было.


KOW-260907-A-INLINE6

STATUS=FAIL
RC=23

Failed step:

vm160_jsurl_source_map

Error:

qout() got an unexpected keyword argument 'timeout'

Опять чистый harness bug.

До падения уже было подтверждено:

distinct javascript URLs = 1
distinct style attrs = 7
event handlers = 0
Git clean
production exact
stale probe files = 0


KOW-260907-A-INLINE7

Recovery без повторения всего INLINE6.

STATUS=OK
RC=0

Он прочитал evidence INLINE6 и выполнил только недостающий VM160 source-map.

Decision:

HOLD_NONCE_STYLE_HASH_DESIGN

Но единственный HOLD был:

UPSTREAM_IDENTITY_PROBE_NOT_CLEAN

Почему:

direct upstream ответил:

HTTP=301
Content-Encoding=NONE

То есть compression уже был в порядке; harness ошибочно требовал именно HTTP==200.


KOW-260907-A-INLINE8

Исправил probe с правильными forwarded headers.

Результат:

STATUS=OK
RC=0

FORWARDED_IDENTITY_200_OK=true
FORWARDED_IDENTITY_TRANSPORT_OK=true

HOLD_REASONS=[]

DECISION=
ELIGIBLE_FOR_STAGED_SCRIPT_ELEM_NONCE_SOURCE_PREP

Это финально закрыло nonce design.


22. LANE B — FONT/MEDIA

Lane B выполнил независимый read-only audit.

Accepted checkpoint:

KOW-260907-B-FONTMEDIA4
STATUS=OK
RC=0

DECISION=
ELIGIBLE_FOR_FONT_MEDIA_SOURCE_PREP

Подтверждено:

native media attachments = 0
XFMG media = 0
[MEDIA] content = 0
board URL = https://kingofwolk.ru

При этом наличие media-site capability не считалось само по себе runtime requirement.

Lane B после этого прекратил новые checkpoints.

Lane A стал единственным writer.


23. FONT/MEDIA FINAL POLICY

После независимого Lane B audit и дополнительной precommit verification были enforcing добавлены:

font-src 'self' data:;
media-src 'self';

На финальном canary:

FONT_ORIGINS=[]

для непосредственно проверенных текущих страниц, то есть никаких новых внешних font runtime origins выявлено не было.

Ранее dedicated audit уже доказал допустимость SELF/DATA.

Rendered media origins также не потребовали дополнительного allow-list.


24. KOW-260907-A-STAGE1 — GIT PROMOTION

Команда:

KOW-260907-A-STAGE1
MODE=change
COMPONENT=kingofwolk-csp-font-media-nonce-stage-git-promotion

Выполнила перед mutation:

- exact Git branch/head/remote/clean checks;
- production old edge SHA check;
- CSP log baseline;
- NPMplus health;
- font/media audit;
- route checks;
- DB read-only media checks;
- repository validation;
- unit tests.

После этого построила exact candidate:

1. enforcement:

+ font-src 'self' data:;
+ media-src 'self';

2. Report-Only:

+ script-src-elem 'self' 'nonce-$request_id' https://challenges.cloudflare.com;

3. transport:

proxy_set_header Accept-Encoding "";
sub_filter_once off;
sub_filter '<script' '<script nonce="$request_id"';

Не менялись:

style-src 'unsafe-inline'
base script-src 'unsafe-inline'
javascript: behavior

Repository validation:

PASS

Tests:

PASS

Новый Git HEAD:

6f584c5faa76d0067b08977aeb583fa0e4de14aa

Новый source SHA:

458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e

Local == remote.

Во время Stage1 production оставался на старом SHA.


25. KOW-260907-A-STAGE2 — PRODUCTION APPLY

KOW-260907-A-STAGE2
STATUS=OK
RC=0
MODE=change
RESULT_CONTRACT_STATUS=OK
CHANGES_MADE=true

Перед apply были проверены:

runner SHA
Git branch/head/parent/message
source exact bytes
production old SHA
live file owner/mode
CSP log baseline
NPMplus health
nginx -t
public old CSP exact

Runner baseline SHA:

b248a4c32c9cc64e5747e7dce6c7fc0a23f5124a77c71ce72e27a81aceae9d2d

Stage2 sequence:

Phase 1 — APPLY1

Установлен новый candidate.

Проверены:
- nginx -t;
- reload;
- routes;
- exact enforcing CSP;
- dynamic Report-Only nonce;
- каждый HTML <script> получил nonce;
- nonce == header nonce;
- CSP log baseline;
- NPMplus health.

Phase 2 — ROLLBACK PROOF

Production вернули к старому:

fc9fb1dbdb6141bfb43a8122dcf3db435a94da48d0a0c02fe9dfae019b1439c5

Проверены old policies, routes, отсутствие nonce injection, health/log.

Rollback proof:

PASS

Phase 3 — FINAL REAPPLY

Candidate применён повторно.

Финальный edge:

458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e

Git не drifted.

Итоговая reason:

FONT_MEDIA_ENFORCED_AND_SCRIPT_ELEM_NONCE_REPORT_ONLY_STAGED_WITH_ROLLBACK_PROOF


26. PRODUCTION ROLLBACK ARTIFACT

Stage2 создал manual rollback backup в NPMplus VM/container filesystem:

/opt/npmplus/manual-backups/kingofwolk-csp-font-media-nonce-stage-20260907-6f584c5/

Old config:

996.conf.before

Old SHA:

fc9fb1dbdb6141bfb43a8122dcf3db435a94da48d0a0c02fe9dfae019b1439c5

Также stage directory использовался для candidate copy.

Если когда-либо потребуется ручной emergency rollback, сначала всё равно заново проверить текущий live state и не предполагать, что этот backup всё ещё является правильной rollback target после будущих изменений.


27. KOW-260907-A-FINAL1 — ФИНАЛЬНЫЙ EVIDENCE CLOSURE

STATUS=OK
RC=0
MODE=read-only
CHANGES_MADE=false

Decision:

KINGOFWOLK_CSP_AGREED_STAGE_FINAL_EVIDENCE_CLOSED

Closure scope:

AGREED_SAFE_CSP_STAGE

Проверено:

- Git exact local/remote/clean;
- source SHA exact;
- production edge SHA exact;
- edge metadata 0:0:644;
- CSP log baseline before;
- NPMplus healthy;
- nginx -t;
- пять HTML routes по два запроса;
- /service_worker.js;
- /webmanifest.php;
- exact security headers;
- exact enforcing CSP;
- exact dynamic Report-Only CSP;
- unique nonce across requests;
- nonce coherence для всех <script>;
- JoyPixels rendered refs + HTTP sample;
- fonts;
- media origins;
- Turnstile signal;
- collector 405/POST;
- CSP log baseline after;
- production SHA after;
- Git state after.

Финал:

NEW_CSP_REPORTS_AFTER_FINAL_BROWSER_CANARY=0
NONCE_UNIQUE_ACROSS_ALL_REQUESTS=true
NPMPLUS_HEALTH_AFTER=healthy


28. CURRENT FINAL HASHES / IDENTIFIERS — БЫСТРЫЙ БЛОК

Git repo:
/root/kingofwolk-csp-baseline-20260907

Branch:
forum/kingofwolk-csp-baseline-20260907

Current HEAD:
6f584c5faa76d0067b08977aeb583fa0e4de14aa

Parent:
3e5d8584ae6887c292cc910c5796e73d8d20aecd

Current source:
deploy/forums/kingofwolk/edge/996.conf

Current source / production edge SHA:
458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e

Previous production SHA:
fc9fb1dbdb6141bfb43a8122dcf3db435a94da48d0a0c02fe9dfae019b1439c5

CSP report log SHA:
2f7977cbfaed7953bd70033c0cf92138eedf974f5886fadf4b4971f2d00a4143

CSP report log lines:
2

Runner SHA:
b248a4c32c9cc64e5747e7dce6c7fc0a23f5124a77c71ce72e27a81aceae9d2d


29. OPERATIONAL COMMAND CONTRACT

Любая новая server command по этому проекту должна сначала читать:

/etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md
/etc/pve/31_HOMELAB_REFERENCE.md

И visibly emit:

ERROR_REGISTER_CHECK=OK
REFERENCE_CHECK=OK
AUTHORITY_CHECK_SCOPE=READABILITY_ONLY_INCIDENTS_NOT_CLOSED

Обычно shell:

root@pve01:~#

Git discipline:

Перед любым change:

git status
branch
local HEAD
remote HEAD

Fail closed при неизвестном drift.

До commit:

git diff --check

Staging:

git add <explicit path>

Не использовать:

git add .

После staging:

git diff --cached --check
git diff --cached

После push:

git fetch
LOCAL_HEAD == REMOTE_HEAD

Запрещённый destructive workflow:

git reset --hard
git clean -fd
force push

Также:

не мутировать XenForo state raw SQL.

Read-only SQL допустим.


30. CONCURRENCY / LANE MODEL

Была доказана настоящая параллельность read-only lanes через direct bridge.

Checkpoint:

KOW-260907-PBR2

Decision:

ALLOW_TRUE_PARALLEL_READ_ONLY_LANES

При этом обычные full runner carriers сериализуются из-за flock.

Checkpoint:

KOW-260907-CONC1

Поэтому правила:

read-only direct bridge:
может работать параллельно

Git commit/push:
ровно один writer

edge production apply:
ровно один writer

XenForo/app mutation:
ровно один writer

Сейчас Lane B закончен.

Если CSP scope снова открыть:

текущий чат/следующий оператор должен оставаться единственным writer.


31. WINDOWS TRANSPORT

Стандартный transport:

.CMD
  -> Windows PowerShell 5.1
  -> Plink
  -> Pageant
  -> SSH pve01
  -> direct bridge или homelab-chat-run
  -> Gitea publication
  -> readback

PuTTY:

C:\Program Files\PuTTY\plink.exe
C:\Program Files\PuTTY\pageant.exe

PPK:

<USERPROFILE>\Documents\ssh\homelab_ed25519.ppk

PPK encrypted:
aes256-cbc

<REDACTED_SENSITIVE_LINE>

<REDACTED_SENSITIVE_LINE>

Нормальный carrier:

plink
-batch
-agent
-ssh
-P 22
-m <remotePath>
root@<PRIVATE_IPV4>

Не использовать обычным carrier:

-i

Pageant должен уже держать unlocked key.

Endpoint:

pve01
root@<PRIVATE_IPV4>:22

PuTTY registry host key:

HKCU:\Software\SimonTatham\PuTTY\SshHostKeys

ssh-ed25519@22:<PRIVATE_IPV4>


32. CARRIER IMPLEMENTATION RULES

PowerShell запускает Plink через:

Start-Process

Обязательно:

separate stdout
separate stderr
real ExitCode
-NoNewWindow

Не использовать:

pause
notepad
MobaXterm

Temp diagnostics:

%TEMP%

Remote command file:

UTF-8
no BOM
one physical line
SHA verified

Большие payloads:

zlib + base64
SHA256 verification
/usr/bin/python3 -I -B

Каждый attempt:

новый COMMAND_ID

Не reuse failed ID.

Runner:

/usr/local/sbin/homelab-chat-run <CID> read-only|change <component> -- <command>

Change result contract:

OK
BLOCKED
FAIL


33. GITEA RUNTIME PUBLICATION / READBACK

Runtime repo:

homelab-admin/homelab-public-context

Branch:

assistant-runtime

Authoritative history path:

runtime/history/<COMMAND_ID>.txt

Главное правило:

не доверять generic latest без проверки COMMAND_ID.

Exact history конкретного command ID — authoritative.

Fast latest может вернуть unrelated/stale result.


34. НЕДАВНИЕ .CMD ARTIFACTS ИЗ ЭТОГО ЧАТА

Последовательность последних команд:

KOW-260907-A-INLINE2
KOW_CSP_INLINE_HASH_FEASIBILITY_AUDIT_PLINK_GITEA_MOSCOW.cmd

KOW-260907-A-INLINE3
KOW_CSP_EDGE_NONCE_UNSAFE_HASHES_FEASIBILITY_PLINK_GITEA_MOSCOW.cmd

KOW-260907-A-INLINE4
KOW_CSP_EDGE_NONCE_UNSAFE_HASHES_FEASIBILITY2_PLINK_GITEA_MOSCOW.cmd

KOW-260907-A-INLINE5
KOW_CSP_EDGE_NONCE_UNSAFE_HASHES_FINAL_FEASIBILITY_PLINK_GITEA_MOSCOW.cmd

KOW-260907-A-INLINE6
KOW_CSP_EDGE_NONCE_STYLE_HASH_JSURL_DESIGN_AUDIT_PLINK_GITEA_MOSCOW.cmd

KOW-260907-A-INLINE7
KOW_CSP_INLINE6_RECOVERY_JSURL_SOURCE_MAP_PLINK_GITEA_MOSCOW.cmd

KOW-260907-A-INLINE8
KOW_CSP_SCRIPT_ELEM_NONCE_DESIGN_CLOSURE_PLINK_GITEA_MOSCOW.cmd

KOW-260907-A-STAGE1
KOW_CSP_FONT_MEDIA_NONCE_STAGE_GIT_PROMOTION_PLINK_GITEA_MOSCOW.cmd

KOW-260907-A-STAGE2
KOW_CSP_FONT_MEDIA_NONCE_STAGE_PRODUCTION_PLINK_GITEA_MOSCOW.cmd

KOW-260907-A-FINAL1
KOW_CSP_FINAL_BROWSER_EVIDENCE_CLOSURE_PLINK_GITEA_MOSCOW.cmd

Не нужно повторно запускать их для «проверки», если нет новой причины.
При новом scope создавать новые IDs.


35. ЧТО НЕ НАДО ПОВТОРЯТЬ СЛЕДУЮЩЕМУ ОПЕРАТОРУ

Уже закрыто, не делать заново без новой фактической причины:

form-action audit
PWA worker/manifest audit
img-src audit
connect-src audit
frame-src audit
JoyPixels path audit
Turnstile static-script search
font-src/media-src discovery
inline hash feasibility
nonce feasibility
upstream identity/compression feasibility
font/media Git promotion
font/media production promotion
rollback proof
final browser/evidence closure

Особенно:

не повторять KOW-260907-B-FONTMEDIA4.

Lane B audit уже accepted.


36. ЧТО ОСТАЁТСЯ ТОЛЬКО КАК FUTURE HARDENING

Это не незавершённые команды текущего counter.

Текущий counter:

0

Future scope можно открыть отдельно.


A. script-src-elem enforcement

Сейчас:

Report-Only only

Nonce транспорт уже работает и финально доказан.

Следующий возможный будущий шаг — только после периода наблюдения CSP report log:

перенести script-src-elem nonce policy в enforcing

Но до этого надо заново проверить:

- log не накопил реальные violations;
- authenticated flows;
- Turnstile;
- admin/Account overlays, если входят в scope;
- все важные XenForo page types.


B. style-src 'unsafe-inline'

Сейчас всё ещё Report-Only:

style-src 'self' 'unsafe-inline'

Причина — style attributes.

Исходное evidence:

31 occurrences
7 distinct values

Нужен отдельный application/template design.


C. javascript: refactor

Есть:

1 distinct javascript: value
несколько rendered occurrences

Не разрешать это через сомнительный hash workaround.

Принятый design:

APPLICATION_REFACTOR_REQUIRED_NOT_HASH_ALLOWLIST


37. ТОЧКА СТАРТА ДЛЯ НОВОГО ЧАТА

Новый чат должен считать canonical current state следующим:

KINGOFWOLK CSP AGREED SAFE STAGE = CLOSED

shared counter = 0

current Git HEAD:
6f584c5faa76d0067b08977aeb583fa0e4de14aa

current edge/source SHA:
458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e

CSP log SHA:
2f7977cbfaed7953bd70033c0cf92138eedf974f5886fadf4b4971f2d00a4143

CSP log lines:
2

NPMplus:
healthy

final accepted checkpoint:
KOW-260907-A-FINAL1

final decision:
KINGOFWOLK_CSP_AGREED_STAGE_FINAL_EVIDENCE_CLOSED

Если пользователь просит «продолжить CSP», сначала надо считать, что открывается новый hardening scope, потому что прежний shared counter уже завершён.

Наиболее логичный новый порядок:

1. read-only observation / authenticated coverage для script-src-elem Report-Only
2. javascript: application refactor design
3. style attribute refactor/hash design
4. только затем новый enforcement promotion с отдельным rollback proof

Но ничего из этого не является долгом старого плана.


38. КОРОТКАЯ ИТОГОВАЯ ФОРМУЛИРОВКА ДЛЯ ВСТАВКИ В НОВЫЙ ЧАТ

KingOfWolk CSP agreed safe stage полностью закрыт. Shared counter = 0.
Финальный accepted checkpoint KOW-260907-A-FINAL1, STATUS=OK, RC=0,
DECISION=KINGOFWOLK_CSP_AGREED_STAGE_FINAL_EVIDENCE_CLOSED.

Текущий Git HEAD:
6f584c5faa76d0067b08977aeb583fa0e4de14aa

Branch:
forum/kingofwolk-csp-baseline-20260907

Worktree:
/root/kingofwolk-csp-baseline-20260907

Source:
deploy/forums/kingofwolk/edge/996.conf

Source/production edge SHA:
458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e

CSP log baseline SHA:
2f7977cbfaed7953bd70033c0cf92138eedf974f5886fadf4b4971f2d00a4143

CSP log:
2 строки

Новых reports после финального browser-canary:
0

NPMplus:
healthy

Enforcing сейчас закрывает:
base-uri
object-src
frame-ancestors
form-action
worker-src
manifest-src
img-src
connect-src
frame-src
font-src
media-src

script-src-elem с per-request $request_id nonce находится только в Report-Only.
Nginx добавляет тот же nonce каждому <script> через sub_filter.
Финальный canary доказал unique nonce per request и полную header↔script coherence.

style-src 'unsafe-inline', существующий javascript: URL и перевод script-src-elem в enforcing оставлены как отдельный будущий hardening scope.

Lane B KOW-260907-B-FONTMEDIA4 accepted и не должен повторяться.

Любой новый change:
- один writer;
- authority checks;
- exact Git/source/edge/log prestate;
- explicit staging;
- rollback proof;
- новый COMMAND_ID.


39. ОСНОВНЫЕ AUTHORITATIVE EVIDENCE URLS

Финальный evidence:
https://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/KOW-260907-A-FINAL1.txt

Production apply:
https://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/KOW-260907-A-STAGE2.txt

Git promotion:
https://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/KOW-260907-A-STAGE1.txt

Lane B font/media:
https://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/KOW-260907-B-FONTMEDIA4.txt

Nonce design closure:
https://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/KOW-260907-A-INLINE8.txt

Inline feasibility:
https://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/KOW-260907-A-INLINE2.txt

Img/connect/frame final recovery:
https://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/KOW-260907-A-ICF6.txt

Img/connect/frame production:
https://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/KOW-260907-A-ICF4.txt

CANDIDATE_LAYER_1_TEXT_END
CANDIDATE_LAYER_2_SOURCE=file:newfi-54-portal200-apply3.cmd::b64::zlib
CANDIDATE_LAYER_2_SHA256=97a1807a275e5d309591f7a7e044d7d56ba5b2f4c6a068df6353bae147707184
CANDIDATE_LAYER_2_BYTES=34155
CANDIDATE_LAYER_2_TEXT_BEGIN
import hashlib
import json
import pathlib
import shlex
import shutil
import subprocess
import sys
import xml.etree.ElementTree as ET

CID = "NEWFI-260907-PORTAL200-APPLY3"
BRANCH = "implementation/newfi-closed-mvp-20260906"
OLD_HEAD = "06bab5acd124e768f70305b3d7eca2f595871890"
WT = pathlib.Path("/root/newfi-portal/.worktrees/implementation-newfi-closed-mvp-20260906")
CAND_ROOT = pathlib.Path("/root/newfi-portal200-candidate/src/addons/Newfi/Portal")
PORTAL_REL = pathlib.Path("src/addons/Newfi/Portal")
GIT_PORTAL = WT / PORTAL_REL
EXPECTED_NEW = {
    "Pub/Controller/Portal.php": (363, "619d489f7cc3b461479c702fa8825097ae22d3c6cfeb911e9f14ea1de9c36d6d"),
    "Service/PortalData.php": (5213, "4da03f26294ab6d163a1a746dee4bb6a82d7d4f39e66b24246bc0eb4c455dd1d"),
    "_data/templates.xml": (10977, "1650c3d868d4bc47703f1b75f1f179cd25309b2d3b20a2b848e81a4423c00861"),
}
EXPECTED_OLD = {
    "Pub/Controller/Portal.php": (247, "53909e753ea5a011550fd280ad1c342a74ca9949be092dfbd95244243cae92f3"),
    "_data/templates.xml": (1461, "c29c04f8b937284e821c87957653e87c2f6ac014fac30648a0f6b18e84392adf"),
}
EXPECTED_GIT_PATHS = [str(PORTAL_REL / p) for p in sorted(EXPECTED_NEW)]
BACKUP = f"/root/newfi-portal200-backup-{CID}"


class PortalError(RuntimeError):
    pass


def emit(key, value=None):
    if value is None:
        print(key, flush=True)
    elif isinstance(value, (dict, list, tuple, bool, int, float)):
        print(
            key + "=" + json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")),
            flush=True,
        )
    else:
        print(key + "=" + str(value), flush=True)


def fail(code):
    raise PortalError(code)


def sha_bytes(data):
    return hashlib.sha256(data).hexdigest()


def file_meta(path):
    data = pathlib.Path(path).read_bytes()
    return len(data), sha_bytes(data)


def run(cmd, *, input_bytes=None, timeout=180, ok=(0,)):
    try:
        p = subprocess.run(
            cmd,
            input=input_bytes,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            timeout=timeout,
            check=False,
        )
    except subprocess.TimeoutExpired:
        fail("SUBPROCESS_TIMEOUT")
    if p.returncode not in ok:
        fail("SUBPROCESS_RC")
    return p


def git(*args, ok=(0,), timeout=120):
    p = run(["/usr/bin/git", "-C", str(WT), *args], timeout=timeout, ok=ok)
    return p.stdout.decode("utf-8", "replace").strip()


def qga(target, argv, *, input_bytes=None, timeout=180):
    if target == "prod":
        cmd = [
            "/usr/sbin/qm",
            "guest",
            "exec",
            "210",
            "--synchronous",
            "1",
            "--timeout",
            str(timeout),
        ]
        if input_bytes is not None:
            cmd += ["--pass-stdin", "1"]
        cmd += ["--", *argv]
    elif target == "staging":
        q = [
            "/usr/sbin/qm",
            "guest",
            "exec",
            "211",
            "--synchronous",
            "1",
            "--timeout",
            str(timeout),
        ]
        if input_bytes is not None:
            q += ["--pass-stdin", "1"]
        q += ["--", *argv]
        remote = (
            "test \"$(id -u):$(hostname -s)\" = 0:pve03 || exit 72; "
            "for f in /etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md /etc/pve/31_HOMELAB_REFERENCE.md; do "
            "test -r \"$f\" && test -s \"$f\" && cat -- \"$f\" >/dev/null || exit 73; done; exec "
            + " ".join(shlex.quote(x) for x in q)
        )
        cmd = [
            "/usr/bin/ssh",
            "-T",
            "-o",
            "BatchMode=yes",
            "-o",
            "StrictHostKeyChecking=yes",
            "-o",
            "UpdateHostKeys=no",
            "-o",
            "ConnectTimeout=10",
            "-o",
            "ConnectionAttempts=1",
            "root@pve03",
            remote,
        ]
    else:
        fail("BAD_TARGET")

    p = run(cmd, input_bytes=input_bytes, timeout=timeout + 30)
    try:
        data = json.loads(p.stdout.decode("utf-8"))
    except Exception:
        fail("QGA_JSON")
    if data.get("exited") not in (1, True) or data.get("exitcode") != 0:
        fail("QGA_GUEST_RC")
    if data.get("out-truncated") or data.get("err-truncated"):
        fail("QGA_TRUNCATED")
    return data.get("out-data", "")


INSPECT_PHP = r'''<?php
$root = '/var/www/forums/newfi/public';
require $root . '/src/XF.php';
\XF::start($root);
$app = \XF::setupApp('XF\\Pub\\App');
$db = $app->db();
function srcMeta(string $path): ?array {
    if (!is_file($path)) { return null; }
    return ['bytes' => filesize($path), 'sha256' => hash_file('sha256', $path)];
}
$portal = $root . '/src/addons/Newfi/Portal';
$templates = $db->fetchAll(
    "SELECT title, style_id, LENGTH(template) AS bytes, SHA2(template,256) AS sha256, " .
    "LOCATE('data-newfi-portal200', template) > 0 AS html_marker, " .
    "LOCATE('--newfi-portal200', template) > 0 AS css_marker " .
    "FROM xf_template WHERE addon_id='Newfi/Portal' ORDER BY style_id,title"
);
$result = [
 'hostname' => gethostname(),
 'application' => [
  'boardActive' => (string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id='boardActive'"),
  'boardUrl' => (string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id='boardUrl'"),
  'indexRoute' => (string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id='indexRoute'"),
  'defaultStyleId' => (string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id='defaultStyleId'"),
  'error_count' => (int)$db->fetchOne("SELECT COUNT(*) FROM xf_error_log"),
  'error_max_id' => (int)$db->fetchOne("SELECT COALESCE(MAX(error_id),0) FROM xf_error_log")
 ],
 'addon' => $db->fetchRow(
  "SELECT addon_id,version_id,version_string,active,is_processing,last_pending_action " .
  "FROM xf_addon WHERE addon_id='Newfi/Portal'"
 ),
 'source' => [
  'Pub/Controller/Portal.php' => srcMeta($portal . '/Pub/Controller/Portal.php'),
  'Service/PortalData.php' => srcMeta($portal . '/Service/PortalData.php'),
  '_data/templates.xml' => srcMeta($portal . '/_data/templates.xml')
 ],
 'templates' => $templates
];
echo "PORTAL200_INSPECT_JSON_BEGIN\n";
echo json_encode(
    $result,
    JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE
);
echo "\nPORTAL200_INSPECT_JSON_END\n";
?>'''


IMPORT_PHP = r'''<?php
$root = '/var/www/forums/newfi/public';
require $root . '/src/XF.php';
\XF::start($root);
$app = \XF::setupApp('XF\\Pub\\App');
$addOn = $app->addOnManager()->getById('Newfi/Portal');
if (!$addOn || !$addOn->isInstalled()) {
    fwrite(STDERR, "ADDON_NOT_INSTALLED\n");
    exit(30);
}
$addOn->preRebuild();
$dataManager = $app->addOnDataManager();
$imported = [];
foreach ($dataManager->getDataTypeClasses() as $dataType) {
    $handler = $dataManager->getDataTypeHandler($dataType);
    $xml = $handler->openTypeFile($addOn->getDataDirectory());
    if (!$xml || !$xml->count()) {
        $handler->deleteAddOnData($addOn->addon_id);
        continue;
    }
    $start = 0;
    $guard = 0;
    while (true) {
        $last = $handler->importAddOnData($addOn->addon_id, $xml, $start, 0);
        if (!$last) { break; }
        if ($last <= $start || ++$guard > 100000) {
            fwrite(STDERR, "IMPORT_PROGRESS_INVALID\n");
            exit(31);
        }
        $start = $last;
    }
    $handler->deleteOrphanedAddOnData($addOn->addon_id, $xml);
    \XF::triggerRunOnce();
    $imported[] = $handler->getContainerTag();
}
$addOn->postDataImport();
$repo = $app->repository(\XF\Repository\StyleRepository::class);
$repo->updateAllStylesLastModifiedDateLater();
$addOn->postRebuild();
echo "PORTAL200_IMPORT_JSON=" . json_encode(
    ['result'=>'PASS','containers'=>$imported],
    JSON_UNESCAPED_SLASHES
) . "\n";
?>'''


ERRORS_PHP_TEMPLATE = r'''<?php
$root='/var/www/forums/newfi/public';
require $root.'/src/XF.php';
\XF::start($root);
$app=\XF::setupApp('XF\\Pub\\App');
$db=$app->db();
$b=%d;
$rows=$db->fetchAll(
    "SELECT error_id,exception_type,message,filename,line FROM xf_error_log WHERE error_id>? ORDER BY error_id",
    [$b]
);
echo json_encode(
    $rows,
    JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE
);
?>'''


RUNTIME_VERIFY_PHP_TEMPLATE = r'''<?php
$state = '__STATE__';
$root = '/var/www/forums/newfi/public';
require $root . '/src/XF.php';
\XF::start($root);
$app = \XF::setupApp('XF\\Pub\\App');
$result = ['state'=>$state];
if ($state === 'new') {
    $service = new \Newfi\Portal\Service\PortalData($app);
    $portal = $service->build();
    $html = (string)$app->templater()->renderTemplate('public:newfi_portal', ['portal'=>$portal]);
    $result['sections'] = [];
    foreach (['forums','resources','media','events','dogs'] as $key) {
        $section = $portal[$key] ?? null;
        if (!is_array($section)) { fwrite(STDERR, "SECTION_MISSING\n"); exit(60); }
        $result['sections'][$key] = [
            'available'=>(bool)($section['available'] ?? false),
            'hasItems'=>(bool)($section['hasItems'] ?? false),
            'status'=>(string)($section['status'] ?? '')
        ];
    }
    $result['html_marker'] = strpos($html, 'data-newfi-portal200="1"') !== false;
    $result['locked_resources'] = strpos($html, 'data-portal-locked="resources"') !== false;
    $result['locked_media'] = strpos($html, 'data-portal-locked="media"') !== false;
    $result['html_bytes'] = strlen($html);
    if (!$result['html_marker']) { fwrite(STDERR, "HTML_MARKER_MISSING\n"); exit(61); }
    if ($result['sections']['resources']['available'] || $result['sections']['media']['available']) {
        fwrite(STDERR, "GUEST_PERMISSION_CONTRACT_CHANGED\n"); exit(62);
    }
    if (!$result['locked_resources'] || !$result['locked_media']) {
        fwrite(STDERR, "LOCKED_COPY_MISSING\n"); exit(63);
    }
    if (!$result['sections']['forums']['available'] || !$result['sections']['events']['available'] || !$result['sections']['dogs']['available']) {
        fwrite(STDERR, "PUBLIC_SECTION_UNAVAILABLE\n"); exit(64);
    }
} else if ($state === 'old') {
    $html = (string)$app->templater()->renderTemplate('public:newfi_portal', []);
    $result['html_marker'] = strpos($html, 'data-newfi-portal200') !== false;
    $result['old_copy'] = strpos($html, 'Закрытое пространство для общения, знаний и материалов сообщества.') !== false;
    $result['html_bytes'] = strlen($html);
    if ($result['html_marker'] || !$result['old_copy']) {
        fwrite(STDERR, "OLD_RENDER_CONTRACT\n"); exit(65);
    }
} else {
    exit(66);
}
echo "PORTAL200_RUNTIME_JSON=" . json_encode(
    $result,
    JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE
) . "\n";
?>'''


HTTP_VERIFY_PY = r'''import html,json,re,subprocess,sys,urllib.parse
mode=sys.argv[1]
state=sys.argv[2]
if mode=='staging':
    host='newfi-staging.gram1.ru'; scheme='http'; port='80'; expected_status=503
elif mode=='prod':
    host='newfi.ru'; scheme='https'; port='443'; expected_status=200
else:
    raise SystemExit(40)
def fetch(url):
    cmd=[
        '/usr/bin/curl','-sS','-k','--max-time','30','--connect-timeout','10'
    ]
    if mode=='staging':
        cmd += ['--resolve',f'{host}:{port}:127.0.0.1']
    cmd += [
        '-o','-','-w','\n__PORTAL_STATUS__%{http_code}',url
    ]
    p=subprocess.run(cmd,stdout=subprocess.PIPE,stderr=subprocess.PIPE,check=False)
    if p.returncode!=0: raise SystemExit(41)
    text=p.stdout.decode('utf-8','replace')
    marker='\n__PORTAL_STATUS__'
    if marker not in text: raise SystemExit(42)
    body,status=text.rsplit(marker,1)
    return int(status.strip()),body
root=f'{scheme}://{host}/'
status,body=fetch(root)
hrefs=[]
for raw in re.findall(r"href=[\"']([^\"']*css\.php[^\"']*)[\"']",body,re.I):
    raw=html.unescape(raw)
    hrefs.append(urllib.parse.urljoin(root,raw))
normal_css=[]
normal_status=[]
for u in hrefs:
    s,b=fetch(u)
    normal_status.append(s)
    normal_css.append(b)
normal_css_text='\n'.join(normal_css)
forced_css_marker=None
forced_css_status=None
if mode=='staging' and state=='new' and hrefs:
    u=urllib.parse.urlsplit(hrefs[0])
    pairs=urllib.parse.parse_qsl(u.query,keep_blank_values=True)
    out=[]
    found=False
    for k,v in pairs:
        if k=='css':
            found=True
            parts=[x for x in v.split(',') if x]
            if 'public:newfi_portal.less' not in parts:
                parts.append('public:newfi_portal.less')
            v=','.join(parts)
        out.append((k,v))
    if found:
        forced=urllib.parse.urlunsplit((u.scheme,u.netloc,u.path,urllib.parse.urlencode(out),u.fragment))
        forced_css_status,forced_body=fetch(forced)
        forced_css_marker='--newfi-portal200' in forced_body
result={
 'status':status,
 'bytes':len(body.encode()),
 'html_marker':'data-newfi-portal200="1"' in body,
 'css_marker':'--newfi-portal200' in normal_css_text,
 'old_copy':'Закрытое пространство для общения, знаний и материалов сообщества.' in body,
 'css_count':len(hrefs),
 'css_status':normal_status,
 'forced_css_status':forced_css_status,
 'forced_css_marker':forced_css_marker,
}
print('PORTAL200_HTTP_JSON='+json.dumps(result,ensure_ascii=False,sort_keys=True,separators=(',',':')))
if status!=expected_status: raise SystemExit(45)
if mode=='prod':
    if not hrefs or any(x!=200 for x in normal_status): raise SystemExit(46)
elif mode=='staging':
    if any(x!=200 for x in normal_status): raise SystemExit(46)
else:
    raise SystemExit(50)
if mode=='prod' and state=='new':
    if not result['html_marker'] or not result['css_marker']: raise SystemExit(47)
elif mode=='prod' and state=='old':
    if result['html_marker'] or result['css_marker'] or not result['old_copy']: raise SystemExit(48)
elif mode=='staging' and state=='new':
    if hrefs and (forced_css_status!=200 or not forced_css_marker): raise SystemExit(49)
elif mode=='staging' and state=='old':
    pass
else:
    raise SystemExit(50)
'''


def parse_inspect(text):
    b = "PORTAL200_INSPECT_JSON_BEGIN\n"
    e = "\nPORTAL200_INSPECT_JSON_END"
    if b not in text or e not in text:
        fail("INSPECT_MARKERS")
    try:
        return json.loads(text.split(b, 1)[1].split(e, 1)[0])
    except Exception:
        fail("INSPECT_JSON")


def inspect(target):
    return parse_inspect(
        qga(target, ["/usr/bin/php8.3"], input_bytes=INSPECT_PHP.encode(), timeout=120)
    )


def source_is_old(data):
    src = data["source"]
    for rel, (size, sha) in EXPECTED_OLD.items():
        row = src.get(rel)
        if not row or row.get("bytes") != size or row.get("sha256") != sha:
            return False
    return src.get("Service/PortalData.php") is None


def source_is_new(data):
    src = data["source"]
    for rel, (size, sha) in EXPECTED_NEW.items():
        row = src.get(rel)
        if not row or row.get("bytes") != size or row.get("sha256") != sha:
            return False
    return True


def template_state_new(data):
    rows = data.get("templates", [])
    rows0 = [r for r in rows if int(r.get("style_id", -1)) == 0]
    titles = [r.get("title") for r in rows0]
    by = {r.get("title"): r for r in rows0}
    return (
        titles == ["newfi_portal", "newfi_portal.less"]
        and int(by["newfi_portal"].get("html_marker", 0)) == 1
        and int(by["newfi_portal.less"].get("css_marker", 0)) == 1
    )


def app_contract(data, target):
    a = data.get("application", {})
    addon = data.get("addon") or {}
    expected = {
        "staging": ("0", "http://newfi-staging.gram1.ru"),
        "prod": ("1", "https://newfi.ru"),
    }[target]
    return (
        a.get("boardActive") == expected[0]
        and a.get("boardUrl") == expected[1]
        and a.get("indexRoute") == "portal/"
        and a.get("defaultStyleId") == "4"
        and addon.get("addon_id") == "Newfi/Portal"
        and int(addon.get("version_id", 0)) == 1000010
        and addon.get("version_string") == "1.0.0 Alpha 1"
        and int(addon.get("active", 0)) == 1
        and int(addon.get("is_processing", 1)) == 0
    )


def new_errors(target, baseline):
    raw = qga(
        target,
        ["/usr/bin/php8.3"],
        input_bytes=(ERRORS_PHP_TEMPLATE % int(baseline)).encode(),
        timeout=90,
    )
    try:
        return json.loads(raw)
    except Exception:
        fail("ERROR_LOG_JSON")


def assert_no_new_errors(target, baseline, phase):
    rows = new_errors(target, baseline)
    if rows:
        emit(
            "PORTAL200_NEW_XF_ERRORS",
            {
                "target": target,
                "phase": phase,
                "count": len(rows),
                "ids": [r.get("error_id") for r in rows],
            },
        )
        fail("NEW_XF_ERRORS")


def guest_backup(target):
    script = f'''set -eu
B={shlex.quote(BACKUP)}
P=/var/www/forums/newfi/public/src/addons/Newfi/Portal
test ! -e "$B"
install -d -m 700 "$B/Pub/Controller" "$B/_data"
cp -p -- "$P/Pub/Controller/Portal.php" "$B/Pub/Controller/Portal.php"
cp -p -- "$P/_data/templates.xml" "$B/_data/templates.xml"
test ! -e "$P/Service/PortalData.php"
printf 'PORTAL200_BACKUP=PASS\n'
'''
    out = qga(target, ["/bin/bash", "-lc", script], timeout=90)
    if "PORTAL200_BACKUP=PASS" not in out:
        fail("BACKUP_FAILED")


def guest_write(target, rel, data):
    dest = f"/var/www/forums/newfi/public/src/addons/Newfi/Portal/{rel}"
    parent = str(pathlib.PurePosixPath(dest).parent)
    script = (
        "set -eu; umask 022; "
        + "install -d -m 755 "
        + shlex.quote(parent)
        + "; T=$(mktemp "
        + shlex.quote(parent + "/.portal200.XXXXXX")
        + "); cat >\"$T\"; chmod 0644 \"$T\"; chown root:root \"$T\"; "
        + "mv -f -- \"$T\" "
        + shlex.quote(dest)
        + "; printf 'PORTAL200_WRITE=PASS\\n'"
    )
    out = qga(
        target,
        ["/bin/bash", "-lc", script],
        input_bytes=data,
        timeout=90,
    )
    if "PORTAL200_WRITE=PASS" not in out:
        fail("WRITE_FAILED")


def install_candidate(target):
    for rel in [
        "Pub/Controller/Portal.php",
        "Service/PortalData.php",
        "_data/templates.xml",
    ]:
        guest_write(target, rel, (CAND_ROOT / rel).read_bytes())


def restore_old(target):
    script = f'''set -eu
B={shlex.quote(BACKUP)}
P=/var/www/forums/newfi/public/src/addons/Newfi/Portal
test -f "$B/Pub/Controller/Portal.php"
test -f "$B/_data/templates.xml"
cp -p -- "$B/Pub/Controller/Portal.php" "$P/Pub/Controller/Portal.php"
cp -p -- "$B/_data/templates.xml" "$P/_data/templates.xml"
rm -f -- "$P/Service/PortalData.php"
rmdir -- "$P/Service" 2>/dev/null || true
printf 'PORTAL200_SOURCE_RESTORE=PASS\n'
'''
    out = qga(target, ["/bin/bash", "-lc", script], timeout=90)
    if "PORTAL200_SOURCE_RESTORE=PASS" not in out:
        fail("SOURCE_RESTORE_FAILED")


def import_addon(target):
    out = qga(
        target,
        ["/usr/bin/php8.3"],
        input_bytes=IMPORT_PHP.encode(),
        timeout=240,
    )
    if "PORTAL200_IMPORT_JSON=" not in out:
        fail("IMPORT_FAILED")


def runtime_verify(target, state):
    src = RUNTIME_VERIFY_PHP_TEMPLATE.replace("__STATE__", state)
    out = qga(target, ["/usr/bin/php8.3"], input_bytes=src.encode(), timeout=120)
    line = next(
        (x for x in out.splitlines() if x.startswith("PORTAL200_RUNTIME_JSON=")),
        None,
    )
    if not line:
        fail("RUNTIME_MARKER_MISSING")
    try:
        data = json.loads(line.split("=", 1)[1])
    except Exception:
        fail("RUNTIME_JSON")
    emit("PORTAL200_RUNTIME", {"target": target, **data})
    return data


def http_verify(target, state):
    argv = ["/usr/bin/python3", "-", target, state]
    if target == "prod":
        try:
            p = subprocess.run(
                argv,
                input=HTTP_VERIFY_PY.encode(),
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                timeout=180,
                check=False,
            )
        except subprocess.TimeoutExpired:
            fail("HTTP_PROD_TIMEOUT")
        out = p.stdout.decode("utf-8", "replace")
        err = p.stderr.decode("utf-8", "replace")
        line = next(
            (x for x in out.splitlines() if x.startswith("PORTAL200_HTTP_JSON=")),
            None,
        )
        data = None
        if line:
            try:
                data = json.loads(line.split("=", 1)[1])
                emit("PORTAL200_HTTP", {"target": target, "state": state, **data})
            except Exception:
                fail("HTTP_JSON")
        if p.returncode != 0:
            emit(
                "PORTAL200_HTTP_EXEC",
                {
                    "target": target,
                    "state": state,
                    "rc": p.returncode,
                    "stderr": err[:800],
                },
            )
            fail("HTTP_PROD_RC")
        if data is None:
            fail("HTTP_MARKER_MISSING")
        return data

    out = qga(
        target,
        argv,
        input_bytes=HTTP_VERIFY_PY.encode(),
        timeout=180,
    )
    line = next(
        (x for x in out.splitlines() if x.startswith("PORTAL200_HTTP_JSON=")),
        None,
    )
    if not line:
        fail("HTTP_MARKER_MISSING")
    try:
        data = json.loads(line.split("=", 1)[1])
    except Exception:
        fail("HTTP_JSON")
    emit("PORTAL200_HTTP", {"target": target, "state": state, **data})
    return data


def verify_new(target, baseline):
    data = inspect(target)
    if not app_contract(data, target):
        fail("APP_CONTRACT_NEW")
    if not source_is_new(data):
        fail("SOURCE_NEW_MISMATCH")
    if not template_state_new(data):
        fail("TEMPLATE_NEW_MISMATCH")
    runtime_verify(target, "new")
    http_verify(target, "new")
    assert_no_new_errors(target, baseline, "verify-new")
    return inspect(target)


def verify_old(target, baseline, old_templates, strict_errors=True):
    data = inspect(target)
    if not app_contract(data, target):
        fail("APP_CONTRACT_OLD")
    if not source_is_old(data):
        fail("SOURCE_OLD_MISMATCH")
    if data.get("templates") != old_templates:
        fail("TEMPLATE_ROLLBACK_MISMATCH")
    if target == "prod":
        runtime_verify(target, "old")
    http_verify(target, "old")
    if strict_errors:
        assert_no_new_errors(target, baseline, "verify-old")
    return inspect(target)


def lint_php(target, source, label):
    out = qga(
        target,
        ["/usr/bin/php8.3", "-l"],
        input_bytes=source.encode(),
        timeout=90,
    )
    if "No syntax errors detected" not in out:
        fail("PHP_LINT_" + label)


def cluster_guard():
    p = run(["/usr/sbin/qm", "status", "210"], timeout=30)
    if "status: running" not in p.stdout.decode():
        fail("VM210_NOT_RUNNING")
    p = run(
        [
            "/usr/bin/ssh",
            "-T",
            "-o",
            "BatchMode=yes",
            "-o",
            "StrictHostKeyChecking=yes",
            "-o",
            "UpdateHostKeys=no",
            "-o",
            "ConnectTimeout=10",
            "root@pve03",
            "/usr/sbin/qm status 211",
        ],
        timeout=40,
    )
    if "status: running" not in p.stdout.decode():
        fail("VM211_NOT_RUNNING")
    emit("PORTAL200_CLUSTER", "PASS")


def result_contract(status, changes, rollback_started, rollback_restored):
    emit(
        "HOMELAB_RESULT_CONTRACT",
        {
            "version": 1,
            "command_id": CID,
            "status": status,
            "changes_made": bool(changes),
            "rollback_started": bool(rollback_started),
            "rollback_restored": rollback_restored,
        },
    )


emit("NEWFI200_BEGIN")
emit("COMMAND_ID", CID)
emit("PORTAL200_REMAINING_FULL_PRODUCT_COMMANDS_BEFORE", 22)
emit(
    "PORTAL200_FLOW",
    "candidate->staging-apply->rollback-proof->staging-reapply->git-push->production",
)

staging_backup = False
staging_mutated = False
staging_final_new = False
prod_backup = False
prod_mutated = False
git_files_changed = False
commit_created = False
push_confirmed = False
failure_rollback_started = False
failure_rollback_restored = None
old_git_bytes = {}
new_head = None
s_error = None
p_error = None
s_old_templates = None
p_old_templates = None

try:
    compile(HTTP_VERIFY_PY, "<portal200-http-verify>", "exec")
    for rel, (size, sha) in EXPECTED_NEW.items():
        p = CAND_ROOT / rel
        if not p.is_file() or file_meta(p) != (size, sha):
            fail("CANDIDATE_IDENTITY")
    tree = ET.parse(CAND_ROOT / "_data/templates.xml")
    titles = sorted(x.attrib.get("title") for x in tree.getroot().findall("template"))
    if titles != ["newfi_portal", "newfi_portal.less"]:
        fail("CANDIDATE_TEMPLATE_SET")
    service = (CAND_ROOT / "Service/PortalData.php").read_text("utf-8")
    if any(
        token in service
        for token in [
            "\\XF::db(",
            "->save(",
            "->delete(",
            "INSERT ",
            "UPDATE ",
            "DELETE ",
            "REPLACE ",
        ]
    ):
        fail("CANDIDATE_MUTATION_SCAN")
    emit("PORTAL200_CANDIDATE_IDENTITY", "PASS")
    emit(
        "PORTAL200_ORCHESTRATION_FIXES",
        {
            "staging_503_without_css_links": True,
            "failure_status": "FAIL",
            "prod_http_via_pve01_public_route": True,
            "css_href_regex_fixed": True,
        },
    )

    branch = git("branch", "--show-current")
    head = git("rev-parse", "HEAD")
    status = git("status", "--porcelain=v1", "--untracked-files=all")
    remote_out = git("ls-remote", "origin", f"refs/heads/{BRANCH}")
    remote = remote_out.split()[0] if remote_out else ""
    if branch != BRANCH or head != OLD_HEAD or remote != OLD_HEAD or status:
        fail("GIT_PRESTATE")
    emit(
        "PORTAL200_GIT_PRESTATE",
        {"branch": branch, "head": head, "remote": remote, "clean": True},
    )
    cluster_guard()

    lint_php("staging", INSPECT_PHP, "INSPECT_STAGING")
    lint_php("staging", IMPORT_PHP, "IMPORT_STAGING")
    lint_php("staging", RUNTIME_VERIFY_PHP_TEMPLATE.replace("__STATE__", "new"), "RUNTIME_STAGING")
    lint_php("prod", INSPECT_PHP, "INSPECT_PROD")
    lint_php("prod", IMPORT_PHP, "IMPORT_PROD")
    lint_php("prod", RUNTIME_VERIFY_PHP_TEMPLATE.replace("__STATE__", "new"), "RUNTIME_PROD")
    emit("PORTAL200_RUNTIME_HELPER_LINT", "PASS")

    s0 = inspect("staging")
    p0 = inspect("prod")
    if not app_contract(s0, "staging") or not app_contract(p0, "prod"):
        fail("APP_PRESTATE")
    if not source_is_old(s0) or not source_is_old(p0):
        fail("SOURCE_PRESTATE")
    if s0.get("templates") != p0.get("templates"):
        fail("TEMPLATE_PRESTATE_PARITY")
    s_error = int(s0["application"]["error_max_id"])
    p_error = int(p0["application"]["error_max_id"])
    s_old_templates = s0["templates"]
    p_old_templates = p0["templates"]
    emit("PORTAL200_ERROR_BASELINE", {"staging": s_error, "prod": p_error})
    http_verify("staging", "old")
    runtime_verify("prod", "old")
    http_verify("prod", "old")
    assert_no_new_errors("staging", s_error, "preflight")
    assert_no_new_errors("prod", p_error, "preflight")
    emit("PORTAL200_PREFLIGHT", "PASS")

    guest_backup("staging")
    staging_backup = True
    staging_mutated = True
    install_candidate("staging")
    import_addon("staging")
    verify_new("staging", s_error)
    emit("PORTAL200_STAGING_FIRST_APPLY", "PASS")

    restore_old("staging")
    import_addon("staging")
    verify_old("staging", s_error, s_old_templates)
    staging_mutated = False
    emit("PORTAL200_STAGING_ROLLBACK_PROOF", "PASS")

    staging_mutated = True
    install_candidate("staging")
    import_addon("staging")
    verify_new("staging", s_error)
    staging_final_new = True
    emit("PORTAL200_STAGING_FINAL", "PASS")

    for rel in EXPECTED_NEW:
        dest = GIT_PORTAL / rel
        old_git_bytes[str(rel)] = dest.read_bytes() if dest.exists() else None
        dest.parent.mkdir(parents=True, exist_ok=True)
        shutil.copyfile(CAND_ROOT / rel, dest)
    git_files_changed = True

    if git("diff", "--check"):
        fail("GIT_DIFF_CHECK")
    status_lines = git("status", "--porcelain=v1", "--untracked-files=all").splitlines()
    actual_paths = sorted(line[3:] for line in status_lines if len(line) >= 4)
    if actual_paths != sorted(EXPECTED_GIT_PATHS):
        fail("GIT_SCOPE")
    git("add", "--", *EXPECTED_GIT_PATHS)
    staged = sorted(x for x in git("diff", "--cached", "--name-only").splitlines() if x)
    if staged != sorted(EXPECTED_GIT_PATHS):
        fail("GIT_STAGED_SCOPE")
    git("commit", "-m", f"feat: build data-backed Newfi portal [{CID}]")
    commit_created = True
    new_head = git("rev-parse", "HEAD")
    if new_head == OLD_HEAD:
        fail("GIT_COMMIT_HEAD")
    if git("status", "--porcelain=v1", "--untracked-files=all"):
        fail("GIT_POSTCOMMIT_DIRTY")
    git("push", "origin", f"HEAD:refs/heads/{BRANCH}", timeout=180)
    remote_out = git("ls-remote", "origin", f"refs/heads/{BRANCH}")
    remote_head = remote_out.split()[0] if remote_out else ""
    if remote_head != new_head:
        fail("GIT_REMOTE_HEAD")
    push_confirmed = True
    emit(
        "PORTAL200_GIT_PROMOTION",
        {
            "old_head": OLD_HEAD,
            "new_head": new_head,
            "remote_head": remote_head,
            "result": "PASS",
        },
    )

    p_pre = inspect("prod")
    if not source_is_old(p_pre) or p_pre.get("templates") != p_old_templates:
        fail("PROD_DRIFT_BEFORE_APPLY")
    if int(p_pre["application"]["error_max_id"]) != p_error:
        fail("PROD_ERROR_DRIFT")
    guest_backup("prod")
    prod_backup = True
    prod_mutated = True
    install_candidate("prod")
    import_addon("prod")
    verify_new("prod", p_error)
    emit("PORTAL200_PRODUCTION", "PASS")

    sf = verify_new("staging", s_error)
    pf = verify_new("prod", p_error)
    if sf["source"] != pf["source"]:
        fail("FINAL_SOURCE_PARITY")
    if sf["templates"] != pf["templates"]:
        fail("FINAL_TEMPLATE_PARITY")
    if git("rev-parse", "HEAD") != new_head:
        fail("FINAL_LOCAL_HEAD")
    remote_out = git("ls-remote", "origin", f"refs/heads/{BRANCH}")
    if not remote_out or remote_out.split()[0] != new_head:
        fail("FINAL_REMOTE_HEAD")
    if git("status", "--porcelain=v1", "--untracked-files=all"):
        fail("FINAL_GIT_DIRTY")
    cluster_guard()
    emit(
        "PORTAL200_FINAL_EVIDENCE",
        {
            "commit": new_head,
            "source_files": 3,
            "templates": 2,
            "staging_error_baseline": s_error,
            "prod_error_baseline": p_error,
        },
    )
    emit("PORTAL200_PORTAL", "PASS")
    emit("PORTAL200_STAGING", "PASS")
    emit("PORTAL200_PRODUCTION", "PASS")
    emit("PORTAL200_REMAINING_FULL_PRODUCT_COMMANDS_AFTER_PASS", 21)
    result_contract("OK", True, False, None)
    emit("NEWFI200_END")
    raise SystemExit(0)

except PortalError as exc:
    code = str(exc)
    emit("PORTAL200_FAILURE", code)

    if push_confirmed:
        if prod_mutated and prod_backup:
            failure_rollback_started = True
            try:
                restore_old("prod")
                import_addon("prod")
                verify_old("prod", p_error, p_old_templates, strict_errors=False)
                prod_mutated = False
                failure_rollback_restored = False
                emit("PORTAL200_FAILURE_PROD_ROLLBACK", "PASS_PARTIAL_OVERALL_STATE_CHANGED")
            except Exception:
                failure_rollback_restored = False
                emit("PORTAL200_FAILURE_PROD_ROLLBACK", "FAIL")
    else:
        if staging_mutated and staging_backup:
            failure_rollback_started = True
            try:
                restore_old("staging")
                import_addon("staging")
                verify_old("staging", s_error, s_old_templates, strict_errors=False)
                staging_mutated = False
                staging_final_new = False
                failure_rollback_restored = True
                emit("PORTAL200_FAILURE_STAGING_ROLLBACK", "PASS")
            except Exception:
                failure_rollback_restored = False
                emit("PORTAL200_FAILURE_STAGING_ROLLBACK", "FAIL")

        if git_files_changed and not commit_created:
            try:
                for rel, old in old_git_bytes.items():
                    dest = GIT_PORTAL / rel
                    if old is None:
                        if dest.exists():
                            dest.unlink()
                    else:
                        dest.write_bytes(old)
                service_dir = GIT_PORTAL / "Service"
                try:
                    service_dir.rmdir()
                except OSError:
                    pass
                emit("PORTAL200_FAILURE_GIT_WORKTREE_RESTORE", "PASS")
            except Exception:
                emit("PORTAL200_FAILURE_GIT_WORKTREE_RESTORE", "FAIL")

        if commit_created:
            emit(
                "PORTAL200_FAILURE_GIT_HISTORY",
                "COMMIT_CREATED_NO_DESTRUCTIVE_RESET_FRESH_ID_REQUIRED",
            )

    persisted = bool(push_confirmed or commit_created or staging_final_new or prod_mutated)
    if failure_rollback_started and failure_rollback_restored is None:
        failure_rollback_restored = False
    result_contract(
        "FAIL",
        persisted,
        failure_rollback_started,
        failure_rollback_restored,
    )
    emit("PORTAL200_ROADMAP_REMAINS", 22)
    emit("NEWFI200_END")
    raise SystemExit(1)

except Exception:
    emit("PORTAL200_FAILURE", "UNEXPECTED_EXCEPTION")
    result_contract(
        "FAIL",
        bool(push_confirmed or commit_created or staging_final_new or prod_mutated),
        False,
        None,
    )
    emit("PORTAL200_ROADMAP_REMAINS", 22)
    emit("NEWFI200_END")
    raise SystemExit(1)

CANDIDATE_LAYER_2_TEXT_END
CANDIDATE_LAYER_3_SOURCE=file:newfi-48-portal200-apply2.cmd::b64::zlib
CANDIDATE_LAYER_3_SHA256=5e3aa56505b7d7ef5b7f0cf588f89d5e1c5f6b8bd9bffc13b8debdbdc5be26c7
CANDIDATE_LAYER_3_BYTES=32663
CANDIDATE_LAYER_3_TEXT_BEGIN
import hashlib
import json
import pathlib
import shlex
import shutil
import subprocess
import sys
import xml.etree.ElementTree as ET

CID = "NEWFI-260907-PORTAL200-APPLY2"
BRANCH = "implementation/newfi-closed-mvp-20260906"
OLD_HEAD = "06bab5acd124e768f70305b3d7eca2f595871890"
WT = pathlib.Path("/root/newfi-portal/.worktrees/implementation-newfi-closed-mvp-20260906")
CAND_ROOT = pathlib.Path("/root/newfi-portal200-candidate/src/addons/Newfi/Portal")
PORTAL_REL = pathlib.Path("src/addons/Newfi/Portal")
GIT_PORTAL = WT / PORTAL_REL
EXPECTED_NEW = {
    "Pub/Controller/Portal.php": (363, "619d489f7cc3b461479c702fa8825097ae22d3c6cfeb911e9f14ea1de9c36d6d"),
    "Service/PortalData.php": (5213, "4da03f26294ab6d163a1a746dee4bb6a82d7d4f39e66b24246bc0eb4c455dd1d"),
    "_data/templates.xml": (10977, "1650c3d868d4bc47703f1b75f1f179cd25309b2d3b20a2b848e81a4423c00861"),
}
EXPECTED_OLD = {
    "Pub/Controller/Portal.php": (247, "53909e753ea5a011550fd280ad1c342a74ca9949be092dfbd95244243cae92f3"),
    "_data/templates.xml": (1461, "c29c04f8b937284e821c87957653e87c2f6ac014fac30648a0f6b18e84392adf"),
}
EXPECTED_GIT_PATHS = [str(PORTAL_REL / p) for p in sorted(EXPECTED_NEW)]
BACKUP = f"/root/newfi-portal200-backup-{CID}"


class PortalError(RuntimeError):
    pass


def emit(key, value=None):
    if value is None:
        print(key, flush=True)
    elif isinstance(value, (dict, list, tuple, bool, int, float)):
        print(
            key + "=" + json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")),
            flush=True,
        )
    else:
        print(key + "=" + str(value), flush=True)


def fail(code):
    raise PortalError(code)


def sha_bytes(data):
    return hashlib.sha256(data).hexdigest()


def file_meta(path):
    data = pathlib.Path(path).read_bytes()
    return len(data), sha_bytes(data)


def run(cmd, *, input_bytes=None, timeout=180, ok=(0,)):
    try:
        p = subprocess.run(
            cmd,
            input=input_bytes,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            timeout=timeout,
            check=False,
        )
    except subprocess.TimeoutExpired:
        fail("SUBPROCESS_TIMEOUT")
    if p.returncode not in ok:
        fail("SUBPROCESS_RC")
    return p


def git(*args, ok=(0,), timeout=120):
    p = run(["/usr/bin/git", "-C", str(WT), *args], timeout=timeout, ok=ok)
    return p.stdout.decode("utf-8", "replace").strip()


def qga(target, argv, *, input_bytes=None, timeout=180):
    if target == "prod":
        cmd = [
            "/usr/sbin/qm",
            "guest",
            "exec",
            "210",
            "--synchronous",
            "1",
            "--timeout",
            str(timeout),
        ]
        if input_bytes is not None:
            cmd += ["--pass-stdin", "1"]
        cmd += ["--", *argv]
    elif target == "staging":
        q = [
            "/usr/sbin/qm",
            "guest",
            "exec",
            "211",
            "--synchronous",
            "1",
            "--timeout",
            str(timeout),
        ]
        if input_bytes is not None:
            q += ["--pass-stdin", "1"]
        q += ["--", *argv]
        remote = (
            "test \"$(id -u):$(hostname -s)\" = 0:pve03 || exit 72; "
            "for f in /etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md /etc/pve/31_HOMELAB_REFERENCE.md; do "
            "test -r \"$f\" && test -s \"$f\" && cat -- \"$f\" >/dev/null || exit 73; done; exec "
            + " ".join(shlex.quote(x) for x in q)
        )
        cmd = [
            "/usr/bin/ssh",
            "-T",
            "-o",
            "BatchMode=yes",
            "-o",
            "StrictHostKeyChecking=yes",
            "-o",
            "UpdateHostKeys=no",
            "-o",
            "ConnectTimeout=10",
            "-o",
            "ConnectionAttempts=1",
            "root@pve03",
            remote,
        ]
    else:
        fail("BAD_TARGET")

    p = run(cmd, input_bytes=input_bytes, timeout=timeout + 30)
    try:
        data = json.loads(p.stdout.decode("utf-8"))
    except Exception:
        fail("QGA_JSON")
    if data.get("exited") not in (1, True) or data.get("exitcode") != 0:
        fail("QGA_GUEST_RC")
    if data.get("out-truncated") or data.get("err-truncated"):
        fail("QGA_TRUNCATED")
    return data.get("out-data", "")


INSPECT_PHP = r'''<?php
$root = '/var/www/forums/newfi/public';
require $root . '/src/XF.php';
\XF::start($root);
$app = \XF::setupApp('XF\\Pub\\App');
$db = $app->db();
function srcMeta(string $path): ?array {
    if (!is_file($path)) { return null; }
    return ['bytes' => filesize($path), 'sha256' => hash_file('sha256', $path)];
}
$portal = $root . '/src/addons/Newfi/Portal';
$templates = $db->fetchAll(
    "SELECT title, style_id, LENGTH(template) AS bytes, SHA2(template,256) AS sha256, " .
    "LOCATE('data-newfi-portal200', template) > 0 AS html_marker, " .
    "LOCATE('--newfi-portal200', template) > 0 AS css_marker " .
    "FROM xf_template WHERE addon_id='Newfi/Portal' ORDER BY style_id,title"
);
$result = [
 'hostname' => gethostname(),
 'application' => [
  'boardActive' => (string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id='boardActive'"),
  'boardUrl' => (string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id='boardUrl'"),
  'indexRoute' => (string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id='indexRoute'"),
  'defaultStyleId' => (string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id='defaultStyleId'"),
  'error_count' => (int)$db->fetchOne("SELECT COUNT(*) FROM xf_error_log"),
  'error_max_id' => (int)$db->fetchOne("SELECT COALESCE(MAX(error_id),0) FROM xf_error_log")
 ],
 'addon' => $db->fetchRow(
  "SELECT addon_id,version_id,version_string,active,is_processing,last_pending_action " .
  "FROM xf_addon WHERE addon_id='Newfi/Portal'"
 ),
 'source' => [
  'Pub/Controller/Portal.php' => srcMeta($portal . '/Pub/Controller/Portal.php'),
  'Service/PortalData.php' => srcMeta($portal . '/Service/PortalData.php'),
  '_data/templates.xml' => srcMeta($portal . '/_data/templates.xml')
 ],
 'templates' => $templates
];
echo "PORTAL200_INSPECT_JSON_BEGIN\n";
echo json_encode(
    $result,
    JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE
);
echo "\nPORTAL200_INSPECT_JSON_END\n";
?>'''


IMPORT_PHP = r'''<?php
$root = '/var/www/forums/newfi/public';
require $root . '/src/XF.php';
\XF::start($root);
$app = \XF::setupApp('XF\\Pub\\App');
$addOn = $app->addOnManager()->getById('Newfi/Portal');
if (!$addOn || !$addOn->isInstalled()) {
    fwrite(STDERR, "ADDON_NOT_INSTALLED\n");
    exit(30);
}
$addOn->preRebuild();
$dataManager = $app->addOnDataManager();
$imported = [];
foreach ($dataManager->getDataTypeClasses() as $dataType) {
    $handler = $dataManager->getDataTypeHandler($dataType);
    $xml = $handler->openTypeFile($addOn->getDataDirectory());
    if (!$xml || !$xml->count()) {
        $handler->deleteAddOnData($addOn->addon_id);
        continue;
    }
    $start = 0;
    $guard = 0;
    while (true) {
        $last = $handler->importAddOnData($addOn->addon_id, $xml, $start, 0);
        if (!$last) { break; }
        if ($last <= $start || ++$guard > 100000) {
            fwrite(STDERR, "IMPORT_PROGRESS_INVALID\n");
            exit(31);
        }
        $start = $last;
    }
    $handler->deleteOrphanedAddOnData($addOn->addon_id, $xml);
    \XF::triggerRunOnce();
    $imported[] = $handler->getContainerTag();
}
$addOn->postDataImport();
$repo = $app->repository(\XF\Repository\StyleRepository::class);
$repo->updateAllStylesLastModifiedDateLater();
$addOn->postRebuild();
echo "PORTAL200_IMPORT_JSON=" . json_encode(
    ['result'=>'PASS','containers'=>$imported],
    JSON_UNESCAPED_SLASHES
) . "\n";
?>'''


ERRORS_PHP_TEMPLATE = r'''<?php
$root='/var/www/forums/newfi/public';
require $root.'/src/XF.php';
\XF::start($root);
$app=\XF::setupApp('XF\\Pub\\App');
$db=$app->db();
$b=%d;
$rows=$db->fetchAll(
    "SELECT error_id,exception_type,message,filename,line FROM xf_error_log WHERE error_id>? ORDER BY error_id",
    [$b]
);
echo json_encode(
    $rows,
    JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE
);
?>'''


RUNTIME_VERIFY_PHP_TEMPLATE = r'''<?php
$state = '__STATE__';
$root = '/var/www/forums/newfi/public';
require $root . '/src/XF.php';
\XF::start($root);
$app = \XF::setupApp('XF\\Pub\\App');
$result = ['state'=>$state];
if ($state === 'new') {
    $service = new \Newfi\Portal\Service\PortalData($app);
    $portal = $service->build();
    $html = (string)$app->templater()->renderTemplate('public:newfi_portal', ['portal'=>$portal]);
    $result['sections'] = [];
    foreach (['forums','resources','media','events','dogs'] as $key) {
        $section = $portal[$key] ?? null;
        if (!is_array($section)) { fwrite(STDERR, "SECTION_MISSING\n"); exit(60); }
        $result['sections'][$key] = [
            'available'=>(bool)($section['available'] ?? false),
            'hasItems'=>(bool)($section['hasItems'] ?? false),
            'status'=>(string)($section['status'] ?? '')
        ];
    }
    $result['html_marker'] = strpos($html, 'data-newfi-portal200="1"') !== false;
    $result['locked_resources'] = strpos($html, 'data-portal-locked="resources"') !== false;
    $result['locked_media'] = strpos($html, 'data-portal-locked="media"') !== false;
    $result['html_bytes'] = strlen($html);
    if (!$result['html_marker']) { fwrite(STDERR, "HTML_MARKER_MISSING\n"); exit(61); }
    if ($result['sections']['resources']['available'] || $result['sections']['media']['available']) {
        fwrite(STDERR, "GUEST_PERMISSION_CONTRACT_CHANGED\n"); exit(62);
    }
    if (!$result['locked_resources'] || !$result['locked_media']) {
        fwrite(STDERR, "LOCKED_COPY_MISSING\n"); exit(63);
    }
    if (!$result['sections']['forums']['available'] || !$result['sections']['events']['available'] || !$result['sections']['dogs']['available']) {
        fwrite(STDERR, "PUBLIC_SECTION_UNAVAILABLE\n"); exit(64);
    }
} else if ($state === 'old') {
    $html = (string)$app->templater()->renderTemplate('public:newfi_portal', []);
    $result['html_marker'] = strpos($html, 'data-newfi-portal200') !== false;
    $result['old_copy'] = strpos($html, 'Закрытое пространство для общения, знаний и материалов сообщества.') !== false;
    $result['html_bytes'] = strlen($html);
    if ($result['html_marker'] || !$result['old_copy']) {
        fwrite(STDERR, "OLD_RENDER_CONTRACT\n"); exit(65);
    }
} else {
    exit(66);
}
echo "PORTAL200_RUNTIME_JSON=" . json_encode(
    $result,
    JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE
) . "\n";
?>'''


HTTP_VERIFY_PY = r'''import html,json,re,subprocess,sys,urllib.parse
mode=sys.argv[1]
state=sys.argv[2]
if mode=='staging':
    host='newfi-staging.gram1.ru'; scheme='http'; port='80'; expected_status=503
elif mode=='prod':
    host='newfi.ru'; scheme='https'; port='443'; expected_status=200
else:
    raise SystemExit(40)
def fetch(url):
    cmd=[
        '/usr/bin/curl','-sS','-k','--max-time','30','--connect-timeout','10',
        '--resolve',f'{host}:{port}:127.0.0.1','-o','-','-w',
        '\n__PORTAL_STATUS__%{http_code}',url
    ]
    p=subprocess.run(cmd,stdout=subprocess.PIPE,stderr=subprocess.PIPE,check=False)
    if p.returncode!=0: raise SystemExit(41)
    text=p.stdout.decode('utf-8','replace')
    marker='\n__PORTAL_STATUS__'
    if marker not in text: raise SystemExit(42)
    body,status=text.rsplit(marker,1)
    return int(status.strip()),body
root=f'{scheme}://{host}/'
status,body=fetch(root)
hrefs=[]
for raw in re.findall(r"href=[\"']([^\"']*css\\.php[^\"']*)[\"']",body,re.I):
    raw=html.unescape(raw)
    hrefs.append(urllib.parse.urljoin(root,raw))
normal_css=[]
normal_status=[]
for u in hrefs:
    s,b=fetch(u)
    normal_status.append(s)
    normal_css.append(b)
normal_css_text='\n'.join(normal_css)
forced_css_marker=None
forced_css_status=None
if mode=='staging' and state=='new' and hrefs:
    u=urllib.parse.urlsplit(hrefs[0])
    pairs=urllib.parse.parse_qsl(u.query,keep_blank_values=True)
    out=[]
    found=False
    for k,v in pairs:
        if k=='css':
            found=True
            parts=[x for x in v.split(',') if x]
            if 'public:newfi_portal.less' not in parts:
                parts.append('public:newfi_portal.less')
            v=','.join(parts)
        out.append((k,v))
    if found:
        forced=urllib.parse.urlunsplit((u.scheme,u.netloc,u.path,urllib.parse.urlencode(out),u.fragment))
        forced_css_status,forced_body=fetch(forced)
        forced_css_marker='--newfi-portal200' in forced_body
result={
 'status':status,
 'bytes':len(body.encode()),
 'html_marker':'data-newfi-portal200="1"' in body,
 'css_marker':'--newfi-portal200' in normal_css_text,
 'old_copy':'Закрытое пространство для общения, знаний и материалов сообщества.' in body,
 'css_count':len(hrefs),
 'css_status':normal_status,
 'forced_css_status':forced_css_status,
 'forced_css_marker':forced_css_marker,
}
print('PORTAL200_HTTP_JSON='+json.dumps(result,ensure_ascii=False,sort_keys=True,separators=(',',':')))
if status!=expected_status: raise SystemExit(45)
if mode=='prod':
    if not hrefs or any(x!=200 for x in normal_status): raise SystemExit(46)
elif mode=='staging':
    if any(x!=200 for x in normal_status): raise SystemExit(46)
else:
    raise SystemExit(50)
if mode=='prod' and state=='new':
    if not result['html_marker'] or not result['css_marker']: raise SystemExit(47)
elif mode=='prod' and state=='old':
    if result['html_marker'] or result['css_marker'] or not result['old_copy']: raise SystemExit(48)
elif mode=='staging' and state=='new':
    if hrefs and (forced_css_status!=200 or not forced_css_marker): raise SystemExit(49)
elif mode=='staging' and state=='old':
    pass
else:
    raise SystemExit(50)
'''


def parse_inspect(text):
    b = "PORTAL200_INSPECT_JSON_BEGIN\n"
    e = "\nPORTAL200_INSPECT_JSON_END"
    if b not in text or e not in text:
        fail("INSPECT_MARKERS")
    try:
        return json.loads(text.split(b, 1)[1].split(e, 1)[0])
    except Exception:
        fail("INSPECT_JSON")


def inspect(target):
    return parse_inspect(
        qga(target, ["/usr/bin/php8.3"], input_bytes=INSPECT_PHP.encode(), timeout=120)
    )


def source_is_old(data):
    src = data["source"]
    for rel, (size, sha) in EXPECTED_OLD.items():
        row = src.get(rel)
        if not row or row.get("bytes") != size or row.get("sha256") != sha:
            return False
    return src.get("Service/PortalData.php") is None


def source_is_new(data):
    src = data["source"]
    for rel, (size, sha) in EXPECTED_NEW.items():
        row = src.get(rel)
        if not row or row.get("bytes") != size or row.get("sha256") != sha:
            return False
    return True


def template_state_new(data):
    rows = data.get("templates", [])
    rows0 = [r for r in rows if int(r.get("style_id", -1)) == 0]
    titles = [r.get("title") for r in rows0]
    by = {r.get("title"): r for r in rows0}
    return (
        titles == ["newfi_portal", "newfi_portal.less"]
        and int(by["newfi_portal"].get("html_marker", 0)) == 1
        and int(by["newfi_portal.less"].get("css_marker", 0)) == 1
    )


def app_contract(data, target):
    a = data.get("application", {})
    addon = data.get("addon") or {}
    expected = {
        "staging": ("0", "http://newfi-staging.gram1.ru"),
        "prod": ("1", "https://newfi.ru"),
    }[target]
    return (
        a.get("boardActive") == expected[0]
        and a.get("boardUrl") == expected[1]
        and a.get("indexRoute") == "portal/"
        and a.get("defaultStyleId") == "4"
        and addon.get("addon_id") == "Newfi/Portal"
        and int(addon.get("version_id", 0)) == 1000010
        and addon.get("version_string") == "1.0.0 Alpha 1"
        and int(addon.get("active", 0)) == 1
        and int(addon.get("is_processing", 1)) == 0
    )


def new_errors(target, baseline):
    raw = qga(
        target,
        ["/usr/bin/php8.3"],
        input_bytes=(ERRORS_PHP_TEMPLATE % int(baseline)).encode(),
        timeout=90,
    )
    try:
        return json.loads(raw)
    except Exception:
        fail("ERROR_LOG_JSON")


def assert_no_new_errors(target, baseline, phase):
    rows = new_errors(target, baseline)
    if rows:
        emit(
            "PORTAL200_NEW_XF_ERRORS",
            {
                "target": target,
                "phase": phase,
                "count": len(rows),
                "ids": [r.get("error_id") for r in rows],
            },
        )
        fail("NEW_XF_ERRORS")


def guest_backup(target):
    script = f'''set -eu
B={shlex.quote(BACKUP)}
P=/var/www/forums/newfi/public/src/addons/Newfi/Portal
test ! -e "$B"
install -d -m 700 "$B/Pub/Controller" "$B/_data"
cp -p -- "$P/Pub/Controller/Portal.php" "$B/Pub/Controller/Portal.php"
cp -p -- "$P/_data/templates.xml" "$B/_data/templates.xml"
test ! -e "$P/Service/PortalData.php"
printf 'PORTAL200_BACKUP=PASS\n'
'''
    out = qga(target, ["/bin/bash", "-lc", script], timeout=90)
    if "PORTAL200_BACKUP=PASS" not in out:
        fail("BACKUP_FAILED")


def guest_write(target, rel, data):
    dest = f"/var/www/forums/newfi/public/src/addons/Newfi/Portal/{rel}"
    parent = str(pathlib.PurePosixPath(dest).parent)
    script = (
        "set -eu; umask 022; "
        + "install -d -m 755 "
        + shlex.quote(parent)
        + "; T=$(mktemp "
        + shlex.quote(parent + "/.portal200.XXXXXX")
        + "); cat >\"$T\"; chmod 0644 \"$T\"; chown root:root \"$T\"; "
        + "mv -f -- \"$T\" "
        + shlex.quote(dest)
        + "; printf 'PORTAL200_WRITE=PASS\\n'"
    )
    out = qga(
        target,
        ["/bin/bash", "-lc", script],
        input_bytes=data,
        timeout=90,
    )
    if "PORTAL200_WRITE=PASS" not in out:
        fail("WRITE_FAILED")


def install_candidate(target):
    for rel in [
        "Pub/Controller/Portal.php",
        "Service/PortalData.php",
        "_data/templates.xml",
    ]:
        guest_write(target, rel, (CAND_ROOT / rel).read_bytes())


def restore_old(target):
    script = f'''set -eu
B={shlex.quote(BACKUP)}
P=/var/www/forums/newfi/public/src/addons/Newfi/Portal
test -f "$B/Pub/Controller/Portal.php"
test -f "$B/_data/templates.xml"
cp -p -- "$B/Pub/Controller/Portal.php" "$P/Pub/Controller/Portal.php"
cp -p -- "$B/_data/templates.xml" "$P/_data/templates.xml"
rm -f -- "$P/Service/PortalData.php"
rmdir -- "$P/Service" 2>/dev/null || true
printf 'PORTAL200_SOURCE_RESTORE=PASS\n'
'''
    out = qga(target, ["/bin/bash", "-lc", script], timeout=90)
    if "PORTAL200_SOURCE_RESTORE=PASS" not in out:
        fail("SOURCE_RESTORE_FAILED")


def import_addon(target):
    out = qga(
        target,
        ["/usr/bin/php8.3"],
        input_bytes=IMPORT_PHP.encode(),
        timeout=240,
    )
    if "PORTAL200_IMPORT_JSON=" not in out:
        fail("IMPORT_FAILED")


def runtime_verify(target, state):
    src = RUNTIME_VERIFY_PHP_TEMPLATE.replace("__STATE__", state)
    out = qga(target, ["/usr/bin/php8.3"], input_bytes=src.encode(), timeout=120)
    line = next(
        (x for x in out.splitlines() if x.startswith("PORTAL200_RUNTIME_JSON=")),
        None,
    )
    if not line:
        fail("RUNTIME_MARKER_MISSING")
    try:
        data = json.loads(line.split("=", 1)[1])
    except Exception:
        fail("RUNTIME_JSON")
    emit("PORTAL200_RUNTIME", {"target": target, **data})
    return data


def http_verify(target, state):
    out = qga(
        target,
        ["/usr/bin/python3", "-", target, state],
        input_bytes=HTTP_VERIFY_PY.encode(),
        timeout=180,
    )
    line = next(
        (x for x in out.splitlines() if x.startswith("PORTAL200_HTTP_JSON=")),
        None,
    )
    if not line:
        fail("HTTP_MARKER_MISSING")
    try:
        data = json.loads(line.split("=", 1)[1])
    except Exception:
        fail("HTTP_JSON")
    emit("PORTAL200_HTTP", {"target": target, "state": state, **data})
    return data


def verify_new(target, baseline):
    data = inspect(target)
    if not app_contract(data, target):
        fail("APP_CONTRACT_NEW")
    if not source_is_new(data):
        fail("SOURCE_NEW_MISMATCH")
    if not template_state_new(data):
        fail("TEMPLATE_NEW_MISMATCH")
    runtime_verify(target, "new")
    http_verify(target, "new")
    assert_no_new_errors(target, baseline, "verify-new")
    return inspect(target)


def verify_old(target, baseline, old_templates, strict_errors=True):
    data = inspect(target)
    if not app_contract(data, target):
        fail("APP_CONTRACT_OLD")
    if not source_is_old(data):
        fail("SOURCE_OLD_MISMATCH")
    if data.get("templates") != old_templates:
        fail("TEMPLATE_ROLLBACK_MISMATCH")
    if target == "prod":
        runtime_verify(target, "old")
    http_verify(target, "old")
    if strict_errors:
        assert_no_new_errors(target, baseline, "verify-old")
    return inspect(target)


def lint_php(target, source, label):
    out = qga(
        target,
        ["/usr/bin/php8.3", "-l"],
        input_bytes=source.encode(),
        timeout=90,
    )
    if "No syntax errors detected" not in out:
        fail("PHP_LINT_" + label)


def cluster_guard():
    p = run(["/usr/sbin/qm", "status", "210"], timeout=30)
    if "status: running" not in p.stdout.decode():
        fail("VM210_NOT_RUNNING")
    p = run(
        [
            "/usr/bin/ssh",
            "-T",
            "-o",
            "BatchMode=yes",
            "-o",
            "StrictHostKeyChecking=yes",
            "-o",
            "UpdateHostKeys=no",
            "-o",
            "ConnectTimeout=10",
            "root@pve03",
            "/usr/sbin/qm status 211",
        ],
        timeout=40,
    )
    if "status: running" not in p.stdout.decode():
        fail("VM211_NOT_RUNNING")
    emit("PORTAL200_CLUSTER", "PASS")


def result_contract(status, changes, rollback_started, rollback_restored):
    emit(
        "HOMELAB_RESULT_CONTRACT",
        {
            "version": 1,
            "command_id": CID,
            "status": status,
            "changes_made": bool(changes),
            "rollback_started": bool(rollback_started),
            "rollback_restored": rollback_restored,
        },
    )


emit("NEWFI200_BEGIN")
emit("COMMAND_ID", CID)
emit("PORTAL200_REMAINING_FULL_PRODUCT_COMMANDS_BEFORE", 22)
emit(
    "PORTAL200_FLOW",
    "candidate->staging-apply->rollback-proof->staging-reapply->git-push->production",
)

staging_backup = False
staging_mutated = False
staging_final_new = False
prod_backup = False
prod_mutated = False
git_files_changed = False
commit_created = False
push_confirmed = False
failure_rollback_started = False
failure_rollback_restored = None
old_git_bytes = {}
new_head = None
s_error = None
p_error = None
s_old_templates = None
p_old_templates = None

try:
    compile(HTTP_VERIFY_PY, "<portal200-http-verify>", "exec")
    for rel, (size, sha) in EXPECTED_NEW.items():
        p = CAND_ROOT / rel
        if not p.is_file() or file_meta(p) != (size, sha):
            fail("CANDIDATE_IDENTITY")
    tree = ET.parse(CAND_ROOT / "_data/templates.xml")
    titles = sorted(x.attrib.get("title") for x in tree.getroot().findall("template"))
    if titles != ["newfi_portal", "newfi_portal.less"]:
        fail("CANDIDATE_TEMPLATE_SET")
    service = (CAND_ROOT / "Service/PortalData.php").read_text("utf-8")
    if any(
        token in service
        for token in [
            "\\XF::db(",
            "->save(",
            "->delete(",
            "INSERT ",
            "UPDATE ",
            "DELETE ",
            "REPLACE ",
        ]
    ):
        fail("CANDIDATE_MUTATION_SCAN")
    emit("PORTAL200_CANDIDATE_IDENTITY", "PASS")
    emit("PORTAL200_ORCHESTRATION_FIXES", {"staging_503_without_css_links": True, "failure_status": "FAIL"})

    branch = git("branch", "--show-current")
    head = git("rev-parse", "HEAD")
    status = git("status", "--porcelain=v1", "--untracked-files=all")
    remote_out = git("ls-remote", "origin", f"refs/heads/{BRANCH}")
    remote = remote_out.split()[0] if remote_out else ""
    if branch != BRANCH or head != OLD_HEAD or remote != OLD_HEAD or status:
        fail("GIT_PRESTATE")
    emit(
        "PORTAL200_GIT_PRESTATE",
        {"branch": branch, "head": head, "remote": remote, "clean": True},
    )
    cluster_guard()

    lint_php("staging", INSPECT_PHP, "INSPECT_STAGING")
    lint_php("staging", IMPORT_PHP, "IMPORT_STAGING")
    lint_php("staging", RUNTIME_VERIFY_PHP_TEMPLATE.replace("__STATE__", "new"), "RUNTIME_STAGING")
    lint_php("prod", INSPECT_PHP, "INSPECT_PROD")
    lint_php("prod", IMPORT_PHP, "IMPORT_PROD")
    lint_php("prod", RUNTIME_VERIFY_PHP_TEMPLATE.replace("__STATE__", "new"), "RUNTIME_PROD")
    emit("PORTAL200_RUNTIME_HELPER_LINT", "PASS")

    s0 = inspect("staging")
    p0 = inspect("prod")
    if not app_contract(s0, "staging") or not app_contract(p0, "prod"):
        fail("APP_PRESTATE")
    if not source_is_old(s0) or not source_is_old(p0):
        fail("SOURCE_PRESTATE")
    if s0.get("templates") != p0.get("templates"):
        fail("TEMPLATE_PRESTATE_PARITY")
    s_error = int(s0["application"]["error_max_id"])
    p_error = int(p0["application"]["error_max_id"])
    s_old_templates = s0["templates"]
    p_old_templates = p0["templates"]
    emit("PORTAL200_ERROR_BASELINE", {"staging": s_error, "prod": p_error})
    http_verify("staging", "old")
    runtime_verify("prod", "old")
    http_verify("prod", "old")
    assert_no_new_errors("staging", s_error, "preflight")
    assert_no_new_errors("prod", p_error, "preflight")
    emit("PORTAL200_PREFLIGHT", "PASS")

    guest_backup("staging")
    staging_backup = True
    staging_mutated = True
    install_candidate("staging")
    import_addon("staging")
    verify_new("staging", s_error)
    emit("PORTAL200_STAGING_FIRST_APPLY", "PASS")

    restore_old("staging")
    import_addon("staging")
    verify_old("staging", s_error, s_old_templates)
    staging_mutated = False
    emit("PORTAL200_STAGING_ROLLBACK_PROOF", "PASS")

    staging_mutated = True
    install_candidate("staging")
    import_addon("staging")
    verify_new("staging", s_error)
    staging_final_new = True
    emit("PORTAL200_STAGING_FINAL", "PASS")

    for rel in EXPECTED_NEW:
        dest = GIT_PORTAL / rel
        old_git_bytes[str(rel)] = dest.read_bytes() if dest.exists() else None
        dest.parent.mkdir(parents=True, exist_ok=True)
        shutil.copyfile(CAND_ROOT / rel, dest)
    git_files_changed = True

    if git("diff", "--check"):
        fail("GIT_DIFF_CHECK")
    status_lines = git("status", "--porcelain=v1", "--untracked-files=all").splitlines()
    actual_paths = sorted(line[3:] for line in status_lines if len(line) >= 4)
    if actual_paths != sorted(EXPECTED_GIT_PATHS):
        fail("GIT_SCOPE")
    git("add", "--", *EXPECTED_GIT_PATHS)
    staged = sorted(x for x in git("diff", "--cached", "--name-only").splitlines() if x)
    if staged != sorted(EXPECTED_GIT_PATHS):
        fail("GIT_STAGED_SCOPE")
    git("commit", "-m", f"feat: build data-backed Newfi portal [{CID}]")
    commit_created = True
    new_head = git("rev-parse", "HEAD")
    if new_head == OLD_HEAD:
        fail("GIT_COMMIT_HEAD")
    if git("status", "--porcelain=v1", "--untracked-files=all"):
        fail("GIT_POSTCOMMIT_DIRTY")
    git("push", "origin", f"HEAD:refs/heads/{BRANCH}", timeout=180)
    remote_out = git("ls-remote", "origin", f"refs/heads/{BRANCH}")
    remote_head = remote_out.split()[0] if remote_out else ""
    if remote_head != new_head:
        fail("GIT_REMOTE_HEAD")
    push_confirmed = True
    emit(
        "PORTAL200_GIT_PROMOTION",
        {
            "old_head": OLD_HEAD,
            "new_head": new_head,
            "remote_head": remote_head,
            "result": "PASS",
        },
    )

    p_pre = inspect("prod")
    if not source_is_old(p_pre) or p_pre.get("templates") != p_old_templates:
        fail("PROD_DRIFT_BEFORE_APPLY")
    if int(p_pre["application"]["error_max_id"]) != p_error:
        fail("PROD_ERROR_DRIFT")
    guest_backup("prod")
    prod_backup = True
    prod_mutated = True
    install_candidate("prod")
    import_addon("prod")
    verify_new("prod", p_error)
    emit("PORTAL200_PRODUCTION", "PASS")

    sf = verify_new("staging", s_error)
    pf = verify_new("prod", p_error)
    if sf["source"] != pf["source"]:
        fail("FINAL_SOURCE_PARITY")
    if sf["templates"] != pf["templates"]:
        fail("FINAL_TEMPLATE_PARITY")
    if git("rev-parse", "HEAD") != new_head:
        fail("FINAL_LOCAL_HEAD")
    remote_out = git("ls-remote", "origin", f"refs/heads/{BRANCH}")
    if not remote_out or remote_out.split()[0] != new_head:
        fail("FINAL_REMOTE_HEAD")
    if git("status", "--porcelain=v1", "--untracked-files=all"):
        fail("FINAL_GIT_DIRTY")
    cluster_guard()
    emit(
        "PORTAL200_FINAL_EVIDENCE",
        {
            "commit": new_head,
            "source_files": 3,
            "templates": 2,
            "staging_error_baseline": s_error,
            "prod_error_baseline": p_error,
        },
    )
    emit("PORTAL200_PORTAL", "PASS")
    emit("PORTAL200_STAGING", "PASS")
    emit("PORTAL200_PRODUCTION", "PASS")
    emit("PORTAL200_REMAINING_FULL_PRODUCT_COMMANDS_AFTER_PASS", 21)
    result_contract("OK", True, False, None)
    emit("NEWFI200_END")
    raise SystemExit(0)

except PortalError as exc:
    code = str(exc)
    emit("PORTAL200_FAILURE", code)

    if push_confirmed:
        if prod_mutated and prod_backup:
            failure_rollback_started = True
            try:
                restore_old("prod")
                import_addon("prod")
                verify_old("prod", p_error, p_old_templates, strict_errors=False)
                prod_mutated = False
                failure_rollback_restored = False
                emit("PORTAL200_FAILURE_PROD_ROLLBACK", "PASS_PARTIAL_OVERALL_STATE_CHANGED")
            except Exception:
                failure_rollback_restored = False
                emit("PORTAL200_FAILURE_PROD_ROLLBACK", "FAIL")
    else:
        if staging_mutated and staging_backup:
            failure_rollback_started = True
            try:
                restore_old("staging")
                import_addon("staging")
                verify_old("staging", s_error, s_old_templates, strict_errors=False)
                staging_mutated = False
                staging_final_new = False
                failure_rollback_restored = True
                emit("PORTAL200_FAILURE_STAGING_ROLLBACK", "PASS")
            except Exception:
                failure_rollback_restored = False
                emit("PORTAL200_FAILURE_STAGING_ROLLBACK", "FAIL")

        if git_files_changed and not commit_created:
            try:
                for rel, old in old_git_bytes.items():
                    dest = GIT_PORTAL / rel
                    if old is None:
                        if dest.exists():
                            dest.unlink()
                    else:
                        dest.write_bytes(old)
                service_dir = GIT_PORTAL / "Service"
                try:
                    service_dir.rmdir()
                except OSError:
                    pass
                emit("PORTAL200_FAILURE_GIT_WORKTREE_RESTORE", "PASS")
            except Exception:
                emit("PORTAL200_FAILURE_GIT_WORKTREE_RESTORE", "FAIL")

        if commit_created:
            emit(
                "PORTAL200_FAILURE_GIT_HISTORY",
                "COMMIT_CREATED_NO_DESTRUCTIVE_RESET_FRESH_ID_REQUIRED",
            )

    persisted = bool(push_confirmed or commit_created or staging_final_new or prod_mutated)
    if failure_rollback_started and failure_rollback_restored is None:
        failure_rollback_restored = False
    result_contract(
        "FAIL",
        persisted,
        failure_rollback_started,
        failure_rollback_restored,
    )
    emit("PORTAL200_ROADMAP_REMAINS", 22)
    emit("NEWFI200_END")
    raise SystemExit(1)

except Exception:
    emit("PORTAL200_FAILURE", "UNEXPECTED_EXCEPTION")
    result_contract(
        "FAIL",
        bool(push_confirmed or commit_created or staging_final_new or prod_mutated),
        False,
        None,
    )
    emit("PORTAL200_ROADMAP_REMAINS", 22)
    emit("NEWFI200_END")
    raise SystemExit(1)

CANDIDATE_LAYER_3_TEXT_END
CANDIDATE_LAYER_4_SOURCE=file:newfi-38-portal200-apply.cmd::b64::zlib
CANDIDATE_LAYER_4_SHA256=e9d137ac729d7513d8d9aa1d9fb509005aefaa7bfe03f5cb26b9896fb1310f62
CANDIDATE_LAYER_4_BYTES=32448
CANDIDATE_LAYER_4_TEXT_BEGIN
import hashlib
import json
import pathlib
import shlex
import shutil
import subprocess
import sys
import xml.etree.ElementTree as ET

CID = "NEWFI-260907-PORTAL200-APPLY1"
BRANCH = "implementation/newfi-closed-mvp-20260906"
OLD_HEAD = "06bab5acd124e768f70305b3d7eca2f595871890"
WT = pathlib.Path("/root/newfi-portal/.worktrees/implementation-newfi-closed-mvp-20260906")
CAND_ROOT = pathlib.Path("/root/newfi-portal200-candidate/src/addons/Newfi/Portal")
PORTAL_REL = pathlib.Path("src/addons/Newfi/Portal")
GIT_PORTAL = WT / PORTAL_REL
EXPECTED_NEW = {
    "Pub/Controller/Portal.php": (363, "619d489f7cc3b461479c702fa8825097ae22d3c6cfeb911e9f14ea1de9c36d6d"),
    "Service/PortalData.php": (5213, "4da03f26294ab6d163a1a746dee4bb6a82d7d4f39e66b24246bc0eb4c455dd1d"),
    "_data/templates.xml": (10977, "1650c3d868d4bc47703f1b75f1f179cd25309b2d3b20a2b848e81a4423c00861"),
}
EXPECTED_OLD = {
    "Pub/Controller/Portal.php": (247, "53909e753ea5a011550fd280ad1c342a74ca9949be092dfbd95244243cae92f3"),
    "_data/templates.xml": (1461, "c29c04f8b937284e821c87957653e87c2f6ac014fac30648a0f6b18e84392adf"),
}
EXPECTED_GIT_PATHS = [str(PORTAL_REL / p) for p in sorted(EXPECTED_NEW)]
BACKUP = f"/root/newfi-portal200-backup-{CID}"


class PortalError(RuntimeError):
    pass


def emit(key, value=None):
    if value is None:
        print(key, flush=True)
    elif isinstance(value, (dict, list, tuple, bool, int, float)):
        print(
            key + "=" + json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")),
            flush=True,
        )
    else:
        print(key + "=" + str(value), flush=True)


def fail(code):
    raise PortalError(code)


def sha_bytes(data):
    return hashlib.sha256(data).hexdigest()


def file_meta(path):
    data = pathlib.Path(path).read_bytes()
    return len(data), sha_bytes(data)


def run(cmd, *, input_bytes=None, timeout=180, ok=(0,)):
    try:
        p = subprocess.run(
            cmd,
            input=input_bytes,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            timeout=timeout,
            check=False,
        )
    except subprocess.TimeoutExpired:
        fail("SUBPROCESS_TIMEOUT")
    if p.returncode not in ok:
        fail("SUBPROCESS_RC")
    return p


def git(*args, ok=(0,), timeout=120):
    p = run(["/usr/bin/git", "-C", str(WT), *args], timeout=timeout, ok=ok)
    return p.stdout.decode("utf-8", "replace").strip()


def qga(target, argv, *, input_bytes=None, timeout=180):
    if target == "prod":
        cmd = [
            "/usr/sbin/qm",
            "guest",
            "exec",
            "210",
            "--synchronous",
            "1",
            "--timeout",
            str(timeout),
        ]
        if input_bytes is not None:
            cmd += ["--pass-stdin", "1"]
        cmd += ["--", *argv]
    elif target == "staging":
        q = [
            "/usr/sbin/qm",
            "guest",
            "exec",
            "211",
            "--synchronous",
            "1",
            "--timeout",
            str(timeout),
        ]
        if input_bytes is not None:
            q += ["--pass-stdin", "1"]
        q += ["--", *argv]
        remote = (
            "test \"$(id -u):$(hostname -s)\" = 0:pve03 || exit 72; "
            "for f in /etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md /etc/pve/31_HOMELAB_REFERENCE.md; do "
            "test -r \"$f\" && test -s \"$f\" && cat -- \"$f\" >/dev/null || exit 73; done; exec "
            + " ".join(shlex.quote(x) for x in q)
        )
        cmd = [
            "/usr/bin/ssh",
            "-T",
            "-o",
            "BatchMode=yes",
            "-o",
            "StrictHostKeyChecking=yes",
            "-o",
            "UpdateHostKeys=no",
            "-o",
            "ConnectTimeout=10",
            "-o",
            "ConnectionAttempts=1",
            "root@pve03",
            remote,
        ]
    else:
        fail("BAD_TARGET")

    p = run(cmd, input_bytes=input_bytes, timeout=timeout + 30)
    try:
        data = json.loads(p.stdout.decode("utf-8"))
    except Exception:
        fail("QGA_JSON")
    if data.get("exited") not in (1, True) or data.get("exitcode") != 0:
        fail("QGA_GUEST_RC")
    if data.get("out-truncated") or data.get("err-truncated"):
        fail("QGA_TRUNCATED")
    return data.get("out-data", "")


INSPECT_PHP = r'''<?php
$root = '/var/www/forums/newfi/public';
require $root . '/src/XF.php';
\XF::start($root);
$app = \XF::setupApp('XF\\Pub\\App');
$db = $app->db();
function srcMeta(string $path): ?array {
    if (!is_file($path)) { return null; }
    return ['bytes' => filesize($path), 'sha256' => hash_file('sha256', $path)];
}
$portal = $root . '/src/addons/Newfi/Portal';
$templates = $db->fetchAll(
    "SELECT title, style_id, LENGTH(template) AS bytes, SHA2(template,256) AS sha256, " .
    "LOCATE('data-newfi-portal200', template) > 0 AS html_marker, " .
    "LOCATE('--newfi-portal200', template) > 0 AS css_marker " .
    "FROM xf_template WHERE addon_id='Newfi/Portal' ORDER BY style_id,title"
);
$result = [
 'hostname' => gethostname(),
 'application' => [
  'boardActive' => (string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id='boardActive'"),
  'boardUrl' => (string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id='boardUrl'"),
  'indexRoute' => (string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id='indexRoute'"),
  'defaultStyleId' => (string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id='defaultStyleId'"),
  'error_count' => (int)$db->fetchOne("SELECT COUNT(*) FROM xf_error_log"),
  'error_max_id' => (int)$db->fetchOne("SELECT COALESCE(MAX(error_id),0) FROM xf_error_log")
 ],
 'addon' => $db->fetchRow(
  "SELECT addon_id,version_id,version_string,active,is_processing,last_pending_action " .
  "FROM xf_addon WHERE addon_id='Newfi/Portal'"
 ),
 'source' => [
  'Pub/Controller/Portal.php' => srcMeta($portal . '/Pub/Controller/Portal.php'),
  'Service/PortalData.php' => srcMeta($portal . '/Service/PortalData.php'),
  '_data/templates.xml' => srcMeta($portal . '/_data/templates.xml')
 ],
 'templates' => $templates
];
echo "PORTAL200_INSPECT_JSON_BEGIN\n";
echo json_encode(
    $result,
    JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE
);
echo "\nPORTAL200_INSPECT_JSON_END\n";
?>'''


IMPORT_PHP = r'''<?php
$root = '/var/www/forums/newfi/public';
require $root . '/src/XF.php';
\XF::start($root);
$app = \XF::setupApp('XF\\Pub\\App');
$addOn = $app->addOnManager()->getById('Newfi/Portal');
if (!$addOn || !$addOn->isInstalled()) {
    fwrite(STDERR, "ADDON_NOT_INSTALLED\n");
    exit(30);
}
$addOn->preRebuild();
$dataManager = $app->addOnDataManager();
$imported = [];
foreach ($dataManager->getDataTypeClasses() as $dataType) {
    $handler = $dataManager->getDataTypeHandler($dataType);
    $xml = $handler->openTypeFile($addOn->getDataDirectory());
    if (!$xml || !$xml->count()) {
        $handler->deleteAddOnData($addOn->addon_id);
        continue;
    }
    $start = 0;
    $guard = 0;
    while (true) {
        $last = $handler->importAddOnData($addOn->addon_id, $xml, $start, 0);
        if (!$last) { break; }
        if ($last <= $start || ++$guard > 100000) {
            fwrite(STDERR, "IMPORT_PROGRESS_INVALID\n");
            exit(31);
        }
        $start = $last;
    }
    $handler->deleteOrphanedAddOnData($addOn->addon_id, $xml);
    \XF::triggerRunOnce();
    $imported[] = $handler->getContainerTag();
}
$addOn->postDataImport();
$repo = $app->repository(\XF\Repository\StyleRepository::class);
$repo->updateAllStylesLastModifiedDateLater();
$addOn->postRebuild();
echo "PORTAL200_IMPORT_JSON=" . json_encode(
    ['result'=>'PASS','containers'=>$imported],
    JSON_UNESCAPED_SLASHES
) . "\n";
?>'''


ERRORS_PHP_TEMPLATE = r'''<?php
$root='/var/www/forums/newfi/public';
require $root.'/src/XF.php';
\XF::start($root);
$app=\XF::setupApp('XF\\Pub\\App');
$db=$app->db();
$b=%d;
$rows=$db->fetchAll(
    "SELECT error_id,exception_type,message,filename,line FROM xf_error_log WHERE error_id>? ORDER BY error_id",
    [$b]
);
echo json_encode(
    $rows,
    JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE
);
?>'''


RUNTIME_VERIFY_PHP_TEMPLATE = r'''<?php
$state = '__STATE__';
$root = '/var/www/forums/newfi/public';
require $root . '/src/XF.php';
\XF::start($root);
$app = \XF::setupApp('XF\\Pub\\App');
$result = ['state'=>$state];
if ($state === 'new') {
    $service = new \Newfi\Portal\Service\PortalData($app);
    $portal = $service->build();
    $html = (string)$app->templater()->renderTemplate('public:newfi_portal', ['portal'=>$portal]);
    $result['sections'] = [];
    foreach (['forums','resources','media','events','dogs'] as $key) {
        $section = $portal[$key] ?? null;
        if (!is_array($section)) { fwrite(STDERR, "SECTION_MISSING\n"); exit(60); }
        $result['sections'][$key] = [
            'available'=>(bool)($section['available'] ?? false),
            'hasItems'=>(bool)($section['hasItems'] ?? false),
            'status'=>(string)($section['status'] ?? '')
        ];
    }
    $result['html_marker'] = strpos($html, 'data-newfi-portal200="1"') !== false;
    $result['locked_resources'] = strpos($html, 'data-portal-locked="resources"') !== false;
    $result['locked_media'] = strpos($html, 'data-portal-locked="media"') !== false;
    $result['html_bytes'] = strlen($html);
    if (!$result['html_marker']) { fwrite(STDERR, "HTML_MARKER_MISSING\n"); exit(61); }
    if ($result['sections']['resources']['available'] || $result['sections']['media']['available']) {
        fwrite(STDERR, "GUEST_PERMISSION_CONTRACT_CHANGED\n"); exit(62);
    }
    if (!$result['locked_resources'] || !$result['locked_media']) {
        fwrite(STDERR, "LOCKED_COPY_MISSING\n"); exit(63);
    }
    if (!$result['sections']['forums']['available'] || !$result['sections']['events']['available'] || !$result['sections']['dogs']['available']) {
        fwrite(STDERR, "PUBLIC_SECTION_UNAVAILABLE\n"); exit(64);
    }
} else if ($state === 'old') {
    $html = (string)$app->templater()->renderTemplate('public:newfi_portal', []);
    $result['html_marker'] = strpos($html, 'data-newfi-portal200') !== false;
    $result['old_copy'] = strpos($html, 'Закрытое пространство для общения, знаний и материалов сообщества.') !== false;
    $result['html_bytes'] = strlen($html);
    if ($result['html_marker'] || !$result['old_copy']) {
        fwrite(STDERR, "OLD_RENDER_CONTRACT\n"); exit(65);
    }
} else {
    exit(66);
}
echo "PORTAL200_RUNTIME_JSON=" . json_encode(
    $result,
    JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE
) . "\n";
?>'''


HTTP_VERIFY_PY = r'''import html,json,re,subprocess,sys,urllib.parse
mode=sys.argv[1]
state=sys.argv[2]
if mode=='staging':
    host='newfi-staging.gram1.ru'; scheme='http'; port='80'; expected_status=503
elif mode=='prod':
    host='newfi.ru'; scheme='https'; port='443'; expected_status=200
else:
    raise SystemExit(40)
def fetch(url):
    cmd=[
        '/usr/bin/curl','-sS','-k','--max-time','30','--connect-timeout','10',
        '--resolve',f'{host}:{port}:127.0.0.1','-o','-','-w',
        '\n__PORTAL_STATUS__%{http_code}',url
    ]
    p=subprocess.run(cmd,stdout=subprocess.PIPE,stderr=subprocess.PIPE,check=False)
    if p.returncode!=0: raise SystemExit(41)
    text=p.stdout.decode('utf-8','replace')
    marker='\n__PORTAL_STATUS__'
    if marker not in text: raise SystemExit(42)
    body,status=text.rsplit(marker,1)
    return int(status.strip()),body
root=f'{scheme}://{host}/'
status,body=fetch(root)
hrefs=[]
for raw in re.findall(r"href=[\"']([^\"']*css\\.php[^\"']*)[\"']",body,re.I):
    raw=html.unescape(raw)
    hrefs.append(urllib.parse.urljoin(root,raw))
normal_css=[]
normal_status=[]
for u in hrefs:
    s,b=fetch(u)
    normal_status.append(s)
    normal_css.append(b)
normal_css_text='\n'.join(normal_css)
forced_css_marker=None
forced_css_status=None
if mode=='staging' and state=='new':
    if not hrefs: raise SystemExit(43)
    u=urllib.parse.urlsplit(hrefs[0])
    pairs=urllib.parse.parse_qsl(u.query,keep_blank_values=True)
    out=[]
    found=False
    for k,v in pairs:
        if k=='css':
            found=True
            parts=[x for x in v.split(',') if x]
            if 'public:newfi_portal.less' not in parts:
                parts.append('public:newfi_portal.less')
            v=','.join(parts)
        out.append((k,v))
    if not found: raise SystemExit(44)
    forced=urllib.parse.urlunsplit((u.scheme,u.netloc,u.path,urllib.parse.urlencode(out),u.fragment))
    forced_css_status,forced_body=fetch(forced)
    forced_css_marker='--newfi-portal200' in forced_body
result={
 'status':status,
 'bytes':len(body.encode()),
 'html_marker':'data-newfi-portal200="1"' in body,
 'css_marker':'--newfi-portal200' in normal_css_text,
 'old_copy':'Закрытое пространство для общения, знаний и материалов сообщества.' in body,
 'css_count':len(hrefs),
 'css_status':normal_status,
 'forced_css_status':forced_css_status,
 'forced_css_marker':forced_css_marker,
}
print('PORTAL200_HTTP_JSON='+json.dumps(result,ensure_ascii=False,sort_keys=True,separators=(',',':')))
if status!=expected_status: raise SystemExit(45)
if not hrefs or any(x!=200 for x in normal_status): raise SystemExit(46)
if mode=='prod' and state=='new':
    if not result['html_marker'] or not result['css_marker']: raise SystemExit(47)
elif mode=='prod' and state=='old':
    if result['html_marker'] or result['css_marker'] or not result['old_copy']: raise SystemExit(48)
elif mode=='staging' and state=='new':
    if forced_css_status!=200 or not forced_css_marker: raise SystemExit(49)
elif mode=='staging' and state=='old':
    pass
else:
    raise SystemExit(50)
'''


def parse_inspect(text):
    b = "PORTAL200_INSPECT_JSON_BEGIN\n"
    e = "\nPORTAL200_INSPECT_JSON_END"
    if b not in text or e not in text:
        fail("INSPECT_MARKERS")
    try:
        return json.loads(text.split(b, 1)[1].split(e, 1)[0])
    except Exception:
        fail("INSPECT_JSON")


def inspect(target):
    return parse_inspect(
        qga(target, ["/usr/bin/php8.3"], input_bytes=INSPECT_PHP.encode(), timeout=120)
    )


def source_is_old(data):
    src = data["source"]
    for rel, (size, sha) in EXPECTED_OLD.items():
        row = src.get(rel)
        if not row or row.get("bytes") != size or row.get("sha256") != sha:
            return False
    return src.get("Service/PortalData.php") is None


def source_is_new(data):
    src = data["source"]
    for rel, (size, sha) in EXPECTED_NEW.items():
        row = src.get(rel)
        if not row or row.get("bytes") != size or row.get("sha256") != sha:
            return False
    return True


def template_state_new(data):
    rows = data.get("templates", [])
    rows0 = [r for r in rows if int(r.get("style_id", -1)) == 0]
    titles = [r.get("title") for r in rows0]
    by = {r.get("title"): r for r in rows0}
    return (
        titles == ["newfi_portal", "newfi_portal.less"]
        and int(by["newfi_portal"].get("html_marker", 0)) == 1
        and int(by["newfi_portal.less"].get("css_marker", 0)) == 1
    )


def app_contract(data, target):
    a = data.get("application", {})
    addon = data.get("addon") or {}
    expected = {
        "staging": ("0", "http://newfi-staging.gram1.ru"),
        "prod": ("1", "https://newfi.ru"),
    }[target]
    return (
        a.get("boardActive") == expected[0]
        and a.get("boardUrl") == expected[1]
        and a.get("indexRoute") == "portal/"
        and a.get("defaultStyleId") == "4"
        and addon.get("addon_id") == "Newfi/Portal"
        and int(addon.get("version_id", 0)) == 1000010
        and addon.get("version_string") == "1.0.0 Alpha 1"
        and int(addon.get("active", 0)) == 1
        and int(addon.get("is_processing", 1)) == 0
    )


def new_errors(target, baseline):
    raw = qga(
        target,
        ["/usr/bin/php8.3"],
        input_bytes=(ERRORS_PHP_TEMPLATE % int(baseline)).encode(),
        timeout=90,
    )
    try:
        return json.loads(raw)
    except Exception:
        fail("ERROR_LOG_JSON")


def assert_no_new_errors(target, baseline, phase):
    rows = new_errors(target, baseline)
    if rows:
        emit(
            "PORTAL200_NEW_XF_ERRORS",
            {
                "target": target,
                "phase": phase,
                "count": len(rows),
                "ids": [r.get("error_id") for r in rows],
            },
        )
        fail("NEW_XF_ERRORS")


def guest_backup(target):
    script = f'''set -eu
B={shlex.quote(BACKUP)}
P=/var/www/forums/newfi/public/src/addons/Newfi/Portal
test ! -e "$B"
install -d -m 700 "$B/Pub/Controller" "$B/_data"
cp -p -- "$P/Pub/Controller/Portal.php" "$B/Pub/Controller/Portal.php"
cp -p -- "$P/_data/templates.xml" "$B/_data/templates.xml"
test ! -e "$P/Service/PortalData.php"
printf 'PORTAL200_BACKUP=PASS\n'
'''
    out = qga(target, ["/bin/bash", "-lc", script], timeout=90)
    if "PORTAL200_BACKUP=PASS" not in out:
        fail("BACKUP_FAILED")


def guest_write(target, rel, data):
    dest = f"/var/www/forums/newfi/public/src/addons/Newfi/Portal/{rel}"
    parent = str(pathlib.PurePosixPath(dest).parent)
    script = (
        "set -eu; umask 022; "
        + "install -d -m 755 "
        + shlex.quote(parent)
        + "; T=$(mktemp "
        + shlex.quote(parent + "/.portal200.XXXXXX")
        + "); cat >\"$T\"; chmod 0644 \"$T\"; chown root:root \"$T\"; "
        + "mv -f -- \"$T\" "
        + shlex.quote(dest)
        + "; printf 'PORTAL200_WRITE=PASS\\n'"
    )
    out = qga(
        target,
        ["/bin/bash", "-lc", script],
        input_bytes=data,
        timeout=90,
    )
    if "PORTAL200_WRITE=PASS" not in out:
        fail("WRITE_FAILED")


def install_candidate(target):
    for rel in [
        "Pub/Controller/Portal.php",
        "Service/PortalData.php",
        "_data/templates.xml",
    ]:
        guest_write(target, rel, (CAND_ROOT / rel).read_bytes())


def restore_old(target):
    script = f'''set -eu
B={shlex.quote(BACKUP)}
P=/var/www/forums/newfi/public/src/addons/Newfi/Portal
test -f "$B/Pub/Controller/Portal.php"
test -f "$B/_data/templates.xml"
cp -p -- "$B/Pub/Controller/Portal.php" "$P/Pub/Controller/Portal.php"
cp -p -- "$B/_data/templates.xml" "$P/_data/templates.xml"
rm -f -- "$P/Service/PortalData.php"
rmdir -- "$P/Service" 2>/dev/null || true
printf 'PORTAL200_SOURCE_RESTORE=PASS\n'
'''
    out = qga(target, ["/bin/bash", "-lc", script], timeout=90)
    if "PORTAL200_SOURCE_RESTORE=PASS" not in out:
        fail("SOURCE_RESTORE_FAILED")


def import_addon(target):
    out = qga(
        target,
        ["/usr/bin/php8.3"],
        input_bytes=IMPORT_PHP.encode(),
        timeout=240,
    )
    if "PORTAL200_IMPORT_JSON=" not in out:
        fail("IMPORT_FAILED")


def runtime_verify(target, state):
    src = RUNTIME_VERIFY_PHP_TEMPLATE.replace("__STATE__", state)
    out = qga(target, ["/usr/bin/php8.3"], input_bytes=src.encode(), timeout=120)
    line = next(
        (x for x in out.splitlines() if x.startswith("PORTAL200_RUNTIME_JSON=")),
        None,
    )
    if not line:
        fail("RUNTIME_MARKER_MISSING")
    try:
        data = json.loads(line.split("=", 1)[1])
    except Exception:
        fail("RUNTIME_JSON")
    emit("PORTAL200_RUNTIME", {"target": target, **data})
    return data


def http_verify(target, state):
    out = qga(
        target,
        ["/usr/bin/python3", "-", target, state],
        input_bytes=HTTP_VERIFY_PY.encode(),
        timeout=180,
    )
    line = next(
        (x for x in out.splitlines() if x.startswith("PORTAL200_HTTP_JSON=")),
        None,
    )
    if not line:
        fail("HTTP_MARKER_MISSING")
    try:
        data = json.loads(line.split("=", 1)[1])
    except Exception:
        fail("HTTP_JSON")
    emit("PORTAL200_HTTP", {"target": target, "state": state, **data})
    return data


def verify_new(target, baseline):
    data = inspect(target)
    if not app_contract(data, target):
        fail("APP_CONTRACT_NEW")
    if not source_is_new(data):
        fail("SOURCE_NEW_MISMATCH")
    if not template_state_new(data):
        fail("TEMPLATE_NEW_MISMATCH")
    runtime_verify(target, "new")
    http_verify(target, "new")
    assert_no_new_errors(target, baseline, "verify-new")
    return inspect(target)


def verify_old(target, baseline, old_templates, strict_errors=True):
    data = inspect(target)
    if not app_contract(data, target):
        fail("APP_CONTRACT_OLD")
    if not source_is_old(data):
        fail("SOURCE_OLD_MISMATCH")
    if data.get("templates") != old_templates:
        fail("TEMPLATE_ROLLBACK_MISMATCH")
    if target == "prod":
        runtime_verify(target, "old")
    http_verify(target, "old")
    if strict_errors:
        assert_no_new_errors(target, baseline, "verify-old")
    return inspect(target)


def lint_php(target, source, label):
    out = qga(
        target,
        ["/usr/bin/php8.3", "-l"],
        input_bytes=source.encode(),
        timeout=90,
    )
    if "No syntax errors detected" not in out:
        fail("PHP_LINT_" + label)


def cluster_guard():
    p = run(["/usr/sbin/qm", "status", "210"], timeout=30)
    if "status: running" not in p.stdout.decode():
        fail("VM210_NOT_RUNNING")
    p = run(
        [
            "/usr/bin/ssh",
            "-T",
            "-o",
            "BatchMode=yes",
            "-o",
            "StrictHostKeyChecking=yes",
            "-o",
            "UpdateHostKeys=no",
            "-o",
            "ConnectTimeout=10",
            "root@pve03",
            "/usr/sbin/qm status 211",
        ],
        timeout=40,
    )
    if "status: running" not in p.stdout.decode():
        fail("VM211_NOT_RUNNING")
    emit("PORTAL200_CLUSTER", "PASS")


def result_contract(status, changes, rollback_started, rollback_restored):
    emit(
        "HOMELAB_RESULT_CONTRACT",
        {
            "version": 1,
            "command_id": CID,
            "status": status,
            "changes_made": bool(changes),
            "rollback_started": bool(rollback_started),
            "rollback_restored": rollback_restored,
        },
    )


emit("NEWFI200_BEGIN")
emit("COMMAND_ID", CID)
emit("PORTAL200_REMAINING_FULL_PRODUCT_COMMANDS_BEFORE", 22)
emit(
    "PORTAL200_FLOW",
    "candidate->staging-apply->rollback-proof->staging-reapply->git-push->production",
)

staging_backup = False
staging_mutated = False
staging_final_new = False
prod_backup = False
prod_mutated = False
git_files_changed = False
commit_created = False
push_confirmed = False
failure_rollback_started = False
failure_rollback_restored = None
old_git_bytes = {}
new_head = None
s_error = None
p_error = None
s_old_templates = None
p_old_templates = None

try:
    compile(HTTP_VERIFY_PY, "<portal200-http-verify>", "exec")
    for rel, (size, sha) in EXPECTED_NEW.items():
        p = CAND_ROOT / rel
        if not p.is_file() or file_meta(p) != (size, sha):
            fail("CANDIDATE_IDENTITY")
    tree = ET.parse(CAND_ROOT / "_data/templates.xml")
    titles = sorted(x.attrib.get("title") for x in tree.getroot().findall("template"))
    if titles != ["newfi_portal", "newfi_portal.less"]:
        fail("CANDIDATE_TEMPLATE_SET")
    service = (CAND_ROOT / "Service/PortalData.php").read_text("utf-8")
    if any(
        token in service
        for token in [
            "\\XF::db(",
            "->save(",
            "->delete(",
            "INSERT ",
            "UPDATE ",
            "DELETE ",
            "REPLACE ",
        ]
    ):
        fail("CANDIDATE_MUTATION_SCAN")
    emit("PORTAL200_CANDIDATE_IDENTITY", "PASS")

    branch = git("branch", "--show-current")
    head = git("rev-parse", "HEAD")
    status = git("status", "--porcelain=v1", "--untracked-files=all")
    remote_out = git("ls-remote", "origin", f"refs/heads/{BRANCH}")
    remote = remote_out.split()[0] if remote_out else ""
    if branch != BRANCH or head != OLD_HEAD or remote != OLD_HEAD or status:
        fail("GIT_PRESTATE")
    emit(
        "PORTAL200_GIT_PRESTATE",
        {"branch": branch, "head": head, "remote": remote, "clean": True},
    )
    cluster_guard()

    lint_php("staging", INSPECT_PHP, "INSPECT_STAGING")
    lint_php("staging", IMPORT_PHP, "IMPORT_STAGING")
    lint_php("staging", RUNTIME_VERIFY_PHP_TEMPLATE.replace("__STATE__", "new"), "RUNTIME_STAGING")
    lint_php("prod", INSPECT_PHP, "INSPECT_PROD")
    lint_php("prod", IMPORT_PHP, "IMPORT_PROD")
    lint_php("prod", RUNTIME_VERIFY_PHP_TEMPLATE.replace("__STATE__", "new"), "RUNTIME_PROD")
    emit("PORTAL200_RUNTIME_HELPER_LINT", "PASS")

    s0 = inspect("staging")
    p0 = inspect("prod")
    if not app_contract(s0, "staging") or not app_contract(p0, "prod"):
        fail("APP_PRESTATE")
    if not source_is_old(s0) or not source_is_old(p0):
        fail("SOURCE_PRESTATE")
    if s0.get("templates") != p0.get("templates"):
        fail("TEMPLATE_PRESTATE_PARITY")
    s_error = int(s0["application"]["error_max_id"])
    p_error = int(p0["application"]["error_max_id"])
    s_old_templates = s0["templates"]
    p_old_templates = p0["templates"]
    emit("PORTAL200_ERROR_BASELINE", {"staging": s_error, "prod": p_error})
    http_verify("staging", "old")
    runtime_verify("prod", "old")
    http_verify("prod", "old")
    assert_no_new_errors("staging", s_error, "preflight")
    assert_no_new_errors("prod", p_error, "preflight")
    emit("PORTAL200_PREFLIGHT", "PASS")

    guest_backup("staging")
    staging_backup = True
    staging_mutated = True
    install_candidate("staging")
    import_addon("staging")
    verify_new("staging", s_error)
    emit("PORTAL200_STAGING_FIRST_APPLY", "PASS")

    restore_old("staging")
    import_addon("staging")
    verify_old("staging", s_error, s_old_templates)
    staging_mutated = False
    emit("PORTAL200_STAGING_ROLLBACK_PROOF", "PASS")

    staging_mutated = True
    install_candidate("staging")
    import_addon("staging")
    verify_new("staging", s_error)
    staging_final_new = True
    emit("PORTAL200_STAGING_FINAL", "PASS")

    for rel in EXPECTED_NEW:
        dest = GIT_PORTAL / rel
        old_git_bytes[str(rel)] = dest.read_bytes() if dest.exists() else None
        dest.parent.mkdir(parents=True, exist_ok=True)
        shutil.copyfile(CAND_ROOT / rel, dest)
    git_files_changed = True

    if git("diff", "--check"):
        fail("GIT_DIFF_CHECK")
    status_lines = git("status", "--porcelain=v1", "--untracked-files=all").splitlines()
    actual_paths = sorted(line[3:] for line in status_lines if len(line) >= 4)
    if actual_paths != sorted(EXPECTED_GIT_PATHS):
        fail("GIT_SCOPE")
    git("add", "--", *EXPECTED_GIT_PATHS)
    staged = sorted(x for x in git("diff", "--cached", "--name-only").splitlines() if x)
    if staged != sorted(EXPECTED_GIT_PATHS):
        fail("GIT_STAGED_SCOPE")
    git("commit", "-m", f"feat: build data-backed Newfi portal [{CID}]")
    commit_created = True
    new_head = git("rev-parse", "HEAD")
    if new_head == OLD_HEAD:
        fail("GIT_COMMIT_HEAD")
    if git("status", "--porcelain=v1", "--untracked-files=all"):
        fail("GIT_POSTCOMMIT_DIRTY")
    git("push", "origin", f"HEAD:refs/heads/{BRANCH}", timeout=180)
    remote_out = git("ls-remote", "origin", f"refs/heads/{BRANCH}")
    remote_head = remote_out.split()[0] if remote_out else ""
    if remote_head != new_head:
        fail("GIT_REMOTE_HEAD")
    push_confirmed = True
    emit(
        "PORTAL200_GIT_PROMOTION",
        {
            "old_head": OLD_HEAD,
            "new_head": new_head,
            "remote_head": remote_head,
            "result": "PASS",
        },
    )

    p_pre = inspect("prod")
    if not source_is_old(p_pre) or p_pre.get("templates") != p_old_templates:
        fail("PROD_DRIFT_BEFORE_APPLY")
    if int(p_pre["application"]["error_max_id"]) != p_error:
        fail("PROD_ERROR_DRIFT")
    guest_backup("prod")
    prod_backup = True
    prod_mutated = True
    install_candidate("prod")
    import_addon("prod")
    verify_new("prod", p_error)
    emit("PORTAL200_PRODUCTION", "PASS")

    sf = verify_new("staging", s_error)
    pf = verify_new("prod", p_error)
    if sf["source"] != pf["source"]:
        fail("FINAL_SOURCE_PARITY")
    if sf["templates"] != pf["templates"]:
        fail("FINAL_TEMPLATE_PARITY")
    if git("rev-parse", "HEAD") != new_head:
        fail("FINAL_LOCAL_HEAD")
    remote_out = git("ls-remote", "origin", f"refs/heads/{BRANCH}")
    if not remote_out or remote_out.split()[0] != new_head:
        fail("FINAL_REMOTE_HEAD")
    if git("status", "--porcelain=v1", "--untracked-files=all"):
        fail("FINAL_GIT_DIRTY")
    cluster_guard()
    emit(
        "PORTAL200_FINAL_EVIDENCE",
        {
            "commit": new_head,
            "source_files": 3,
            "templates": 2,
            "staging_error_baseline": s_error,
            "prod_error_baseline": p_error,
        },
    )
    emit("PORTAL200_PORTAL", "PASS")
    emit("PORTAL200_STAGING", "PASS")
    emit("PORTAL200_PRODUCTION", "PASS")
    emit("PORTAL200_REMAINING_FULL_PRODUCT_COMMANDS_AFTER_PASS", 21)
    result_contract("OK", True, False, None)
    emit("NEWFI200_END")
    raise SystemExit(0)

except PortalError as exc:
    code = str(exc)
    emit("PORTAL200_FAILURE", code)

    if push_confirmed:
        if prod_mutated and prod_backup:
            failure_rollback_started = True
            try:
                restore_old("prod")
                import_addon("prod")
                verify_old("prod", p_error, p_old_templates, strict_errors=False)
                prod_mutated = False
                failure_rollback_restored = False
                emit("PORTAL200_FAILURE_PROD_ROLLBACK", "PASS_PARTIAL_OVERALL_STATE_CHANGED")
            except Exception:
                failure_rollback_restored = False
                emit("PORTAL200_FAILURE_PROD_ROLLBACK", "FAIL")
    else:
        if staging_mutated and staging_backup:
            failure_rollback_started = True
            try:
                restore_old("staging")
                import_addon("staging")
                verify_old("staging", s_error, s_old_templates, strict_errors=False)
                staging_mutated = False
                staging_final_new = False
                failure_rollback_restored = True
                emit("PORTAL200_FAILURE_STAGING_ROLLBACK", "PASS")
            except Exception:
                failure_rollback_restored = False
                emit("PORTAL200_FAILURE_STAGING_ROLLBACK", "FAIL")

        if git_files_changed and not commit_created:
            try:
                for rel, old in old_git_bytes.items():
                    dest = GIT_PORTAL / rel
                    if old is None:
                        if dest.exists():
                            dest.unlink()
                    else:
                        dest.write_bytes(old)
                service_dir = GIT_PORTAL / "Service"
                try:
                    service_dir.rmdir()
                except OSError:
                    pass
                emit("PORTAL200_FAILURE_GIT_WORKTREE_RESTORE", "PASS")
            except Exception:
                emit("PORTAL200_FAILURE_GIT_WORKTREE_RESTORE", "FAIL")

        if commit_created:
            emit(
                "PORTAL200_FAILURE_GIT_HISTORY",
                "COMMIT_CREATED_NO_DESTRUCTIVE_RESET_FRESH_ID_REQUIRED",
            )

    persisted = bool(push_confirmed or commit_created or staging_final_new or prod_mutated)
    if failure_rollback_started and failure_rollback_restored is None:
        failure_rollback_restored = False
    result_contract(
        "ERROR",
        persisted,
        failure_rollback_started,
        failure_rollback_restored,
    )
    emit("PORTAL200_ROADMAP_REMAINS", 22)
    emit("NEWFI200_END")
    raise SystemExit(1)

except Exception:
    emit("PORTAL200_FAILURE", "UNEXPECTED_EXCEPTION")
    result_contract(
        "ERROR",
        bool(push_confirmed or commit_created or staging_final_new or prod_mutated),
        False,
        None,
    )
    emit("PORTAL200_ROADMAP_REMAINS", 22)
    emit("NEWFI200_END")
    raise SystemExit(1)

CANDIDATE_LAYER_4_TEXT_END
CANDIDATE_LAYER_5_SOURCE=file:newfi-55-portal200-apply3-rca.cmd::b64::zlib
CANDIDATE_LAYER_5_SHA256=8911318cd314572edf9c6343c09b4c83d283e946dd2398ea2e05722ac9bd44ac
CANDIDATE_LAYER_5_BYTES=10641
CANDIDATE_LAYER_5_TEXT_BEGIN
import hashlib
import json
import pathlib
import shlex
import subprocess

CID = "NEWFI-260907-PORTAL200-APPLY3RCA1"
FAILED_CID = "NEWFI-260907-PORTAL200-APPLY3"
WT = pathlib.Path("/root/newfi-portal/.worktrees/implementation-newfi-closed-mvp-20260906")
BRANCH = "implementation/newfi-closed-mvp-20260906"
OLD_HEAD = "06bab5acd124e768f70305b3d7eca2f595871890"
FAILED_DIR = pathlib.Path("/root/newfi-run-" + FAILED_CID)
WRAPPER = FAILED_DIR / "wrapper.log"

OLD = {
    "controller": "53909e753ea5a011550fd280ad1c342a74ca9949be092dfbd95244243cae92f3",
    "templates_xml": "c29c04f8b937284e821c87957653e87c2f6ac014fac30648a0f6b18e84392adf",
}
NEW = {
    "controller": "619d489f7cc3b461479c702fa8825097ae22d3c6cfeb911e9f14ea1de9c36d6d",
    "service": "4da03f26294ab6d163a1a746dee4bb6a82d7d4f39e66b24246bc0eb4c455dd1d",
    "templates_xml": "1650c3d868d4bc47703f1b75f1f179cd25309b2d3b20a2b848e81a4423c00861",
}

INSPECT = '<?php\n$root=\'/var/www/forums/newfi/public\';\nrequire $root.\'/src/XF.php\';\n\\XF::start($root);\n$app=\\XF::setupApp(\'XF\\\\Pub\\\\App\');\n$db=$app->db();\n\nfunction meta($p) {\n    if (!is_file($p)) { return null; }\n    return [\'bytes\'=>filesize($p),\'sha256\'=>hash_file(\'sha256\',$p)];\n}\n\n$portal=$root.\'/src/addons/Newfi/Portal\';\n$baseline=(gethostname()===\'newfi-staging\') ? 40 : 0;\n\n$templates=$db->fetchAll(\n    "SELECT title,style_id,LENGTH(template) AS bytes,SHA2(template,256) AS sha256," .\n    " LOCATE(\'data-newfi-portal200\',template)>0 AS html_marker," .\n    " LOCATE(\'--newfi-portal200\',template)>0 AS css_marker" .\n    " FROM xf_template WHERE addon_id=\'Newfi/Portal\' ORDER BY style_id,title"\n);\n\n$errors=$db->fetchAll(\n    "SELECT error_id,exception_type,message,filename,line" .\n    " FROM xf_error_log WHERE error_id>? ORDER BY error_id LIMIT 30",\n    [$baseline]\n);\n\n$result=[\n    \'hostname\'=>gethostname(),\n    \'application\'=>[\n        \'boardActive\'=>(string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id=\'boardActive\'"),\n        \'boardUrl\'=>(string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id=\'boardUrl\'"),\n        \'indexRoute\'=>(string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id=\'indexRoute\'"),\n        \'error_count\'=>(int)$db->fetchOne("SELECT COUNT(*) FROM xf_error_log"),\n        \'error_max_id\'=>(int)$db->fetchOne("SELECT COALESCE(MAX(error_id),0) FROM xf_error_log")\n    ],\n    \'source\'=>[\n        \'controller\'=>meta($portal.\'/Pub/Controller/Portal.php\'),\n        \'service\'=>meta($portal.\'/Service/PortalData.php\'),\n        \'templates_xml\'=>meta($portal.\'/_data/templates.xml\')\n    ],\n    \'templates\'=>$templates,\n    \'new_errors\'=>$errors\n];\n\necho "PORTAL200_RCA_GUEST_JSON=" .\n    json_encode($result,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE) .\n    "\\n";\n?>'

def emit(k, v=None):
    if v is None:
        print(k, flush=True)
    elif isinstance(v, (dict, list, bool, int, float)):
        print(k + "=" + json.dumps(v, ensure_ascii=False, sort_keys=True, separators=(",", ":")), flush=True)
    else:
        print(k + "=" + str(v), flush=True)

def run(cmd, *, input_bytes=None, timeout=120):
    return subprocess.run(
        cmd, input=input_bytes, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
        timeout=timeout, check=False
    )

def git(*args):
    p = run(["/usr/bin/git", "-C", str(WT), *args], timeout=90)
    return p.returncode, p.stdout.decode("utf-8", "replace").strip(), p.stderr.decode("utf-8", "replace").strip()

def qga(target, php_source):
    if target == "prod":
        cmd = [
            "/usr/sbin/qm", "guest", "exec", "210",
            "--synchronous", "1", "--timeout", "120",
            "--pass-stdin", "1", "--", "/usr/bin/php8.3"
        ]
    else:
        q = [
            "/usr/sbin/qm", "guest", "exec", "211",
            "--synchronous", "1", "--timeout", "120",
            "--pass-stdin", "1", "--", "/usr/bin/php8.3"
        ]
        remote = (
            "test \"$(id -u):$(hostname -s)\" = 0:pve03 || exit 72; "
            "for f in /etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md /etc/pve/31_HOMELAB_REFERENCE.md; do "
            "test -r \"$f\" && test -s \"$f\" && cat -- \"$f\" >/dev/null || exit 73; done; exec "
            + " ".join(shlex.quote(x) for x in q)
        )
        cmd = [
            "/usr/bin/ssh", "-T",
            "-o", "BatchMode=yes",
            "-o", "StrictHostKeyChecking=yes",
            "-o", "UpdateHostKeys=no",
            "-o", "ConnectTimeout=10",
            "-o", "ConnectionAttempts=1",
            "root@pve03", remote
        ]
    p = run(cmd, input_bytes=php_source.encode("utf-8"), timeout=150)
    if p.returncode != 0:
        return {"transport_rc": p.returncode, "transport_error": p.stderr.decode("utf-8", "replace")[:500]}
    try:
        outer = json.loads(p.stdout.decode("utf-8", "replace"))
    except Exception:
        return {"transport_rc": 0, "outer_json": False, "raw": p.stdout.decode("utf-8", "replace")[:1000]}
    if outer.get("exitcode") != 0:
        return {
            "transport_rc": 0,
            "guest_rc": outer.get("exitcode"),
            "guest_out": outer.get("out-data", "")[:1000],
            "guest_err": outer.get("err-data", "")[:1000],
        }
    out = outer.get("out-data", "")
    marker = "PORTAL200_RCA_GUEST_JSON="
    line = next((x for x in out.splitlines() if x.startswith(marker)), None)
    if not line:
        return {"transport_rc": 0, "guest_rc": 0, "marker": False, "guest_out": out[:1000]}
    return json.loads(line[len(marker):])

def source_state(data):
    src = data.get("source", {})
    controller = (src.get("controller") or {}).get("sha256")
    service = src.get("service")
    templates = (src.get("templates_xml") or {}).get("sha256")
    if controller == OLD["controller"] and service is None and templates == OLD["templates_xml"]:
        return "OLD"
    if (
        controller == NEW["controller"]
        and (service or {}).get("sha256") == NEW["service"]
        and templates == NEW["templates_xml"]
    ):
        return "NEW"
    return "DRIFT"

def template_state(data):
    rows = data.get("templates", [])
    titles = [r.get("title") for r in rows]
    has_html = any(bool(r.get("html_marker")) for r in rows)
    has_css = any(bool(r.get("css_marker")) for r in rows)
    if titles == ["newfi_portal"] and not has_html and not has_css:
        return "OLD"
    if titles == ["newfi_portal", "newfi_portal.less"] and has_html and has_css:
        return "NEW"
    return "DRIFT"

emit("NEWFI55_BEGIN")
emit("COMMAND_ID", CID)
emit("RCA_TARGET_FAILED_COMMAND", FAILED_CID)
emit("PORTAL200_ROADMAP_REMAINS", 22)
emit("RCA_MUTATION_SCOPE", "NONE_READ_ONLY")

if WRAPPER.is_file() and not WRAPPER.is_symlink():
    raw = WRAPPER.read_bytes()
    emit("RCA_WRAPPER_BYTES", len(raw))
    emit("RCA_WRAPPER_SHA256", hashlib.sha256(raw).hexdigest())
    text = raw.decode("utf-8", "replace")
    keep_prefixes = (
        "CHAT_OUTPUT_BEGIN", "CHAT_OUTPUT_END", "COMMAND_ID=", "STATUS=", "RC=",
        "RESULT_CONTRACT_", "COMMAND_RC=", "CHANGES_MADE=", "ROLLBACK_",
        "MUTATION_OUTCOME=", "OUTPUT_BEGIN", "OUTPUT_END", "NEWFI200",
        "PORTAL200_", "HOMELAB_RESULT_CONTRACT=", "STOP=", "Traceback",
    )
    selected = [line for line in text.splitlines() if line.startswith(keep_prefixes)]
    emit("RCA_WRAPPER_SELECTED_LINE_COUNT", len(selected))
    print("RCA_WRAPPER_SELECTED_BEGIN", flush=True)
    for line in selected[-250:]:
        print(line, flush=True)
    print("RCA_WRAPPER_SELECTED_END", flush=True)
else:
    emit("RCA_WRAPPER_MISSING", True)

_, branch, _ = git("branch", "--show-current")
_, head, _ = git("rev-parse", "HEAD")
_, remote_out, _ = git("ls-remote", "origin", f"refs/heads/{BRANCH}")
_, status, _ = git("status", "--porcelain=v1", "--untracked-files=all")
remote = remote_out.split()[0] if remote_out else ""

git_state = {
    "branch": branch,
    "head": head,
    "remote": remote,
    "clean": (status == ""),
    "status_lines": status.splitlines()[:20],
}
emit("RCA_GIT_STATE", git_state)

staging = qga("staging", INSPECT)
prod = qga("prod", INSPECT)

emit("RCA_STAGING_STATE", staging)
emit("RCA_PROD_STATE", prod)

s_src = source_state(staging) if isinstance(staging, dict) else "UNKNOWN"
p_src = source_state(prod) if isinstance(prod, dict) else "UNKNOWN"
s_tpl = template_state(staging) if isinstance(staging, dict) else "UNKNOWN"
p_tpl = template_state(prod) if isinstance(prod, dict) else "UNKNOWN"

summary = {
    "git_old_head": head == OLD_HEAD and remote == OLD_HEAD,
    "git_clean": status == "",
    "staging_source": s_src,
    "staging_templates": s_tpl,
    "staging_error_max": staging.get("application", {}).get("error_max_id") if isinstance(staging, dict) else None,
    "prod_source": p_src,
    "prod_templates": p_tpl,
    "prod_error_max": prod.get("application", {}).get("error_max_id") if isinstance(prod, dict) else None,
}
summary["clean_old_prestate"] = bool(
    summary["git_old_head"]
    and summary["git_clean"]
    and s_src == "OLD"
    and s_tpl == "OLD"
    and p_src == "OLD"
    and p_tpl == "OLD"
)
emit("RCA_STATE_SUMMARY", summary)

runner_path = pathlib.Path("/usr/local/sbin/homelab-chat-run")
if runner_path.is_file() and not runner_path.is_symlink():
    runner_bytes = runner_path.read_bytes()
    runner_text = runner_bytes.decode("utf-8", "replace")
    emit("RCA_RUNNER_SHA256", hashlib.sha256(runner_bytes).hexdigest())
    emit("RCA_RUNNER_BYTES", len(runner_bytes))
    lines = runner_text.splitlines()
    terms = (
        "HOMELAB_RESULT_CONTRACT",
        "rollback_restored",
        "rollback_started",
        "changes_made",
        "status not in",
        "ValueError",
    )
    indexes = set()
    for i, line in enumerate(lines):
        if any(t in line for t in terms):
            for j in range(max(0, i - 3), min(len(lines), i + 4)):
                indexes.add(j)
    print("RCA_RUNNER_CONTRACT_SOURCE_BEGIN", flush=True)
    for j in sorted(indexes):
        line = lines[j]
        if len(line) > 500:
            line = line[:500] + "...[TRUNCATED]"
        print(json.dumps({"line": j + 1, "text": line}, ensure_ascii=False, separators=(",", ":")), flush=True)
    print("RCA_RUNNER_CONTRACT_SOURCE_END", flush=True)
else:
    emit("RCA_RUNNER_SOURCE_MISSING", True)

emit("HOMELAB_RESULT_CONTRACT", {
    "version": 1,
    "command_id": CID,
    "status": "OK",
    "changes_made": False,
    "rollback_started": False,
    "rollback_restored": None
})
emit("NEWFI55_END")

CANDIDATE_LAYER_5_TEXT_END
CANDIDATE_LAYER_6_SOURCE=file:newfi-39-portal200-rca1.cmd::b64::zlib
CANDIDATE_LAYER_6_SHA256=08edd716820df9797dc01cbee765750d9924fc4f94855fde4aba2c25eacb76f2
CANDIDATE_LAYER_6_BYTES=9466
CANDIDATE_LAYER_6_TEXT_BEGIN
import hashlib
import json
import pathlib
import shlex
import subprocess

CID = "NEWFI-260907-PORTAL200-RCA1"
FAILED_CID = "NEWFI-260907-PORTAL200-APPLY1"
WT = pathlib.Path("/root/newfi-portal/.worktrees/implementation-newfi-closed-mvp-20260906")
BRANCH = "implementation/newfi-closed-mvp-20260906"
OLD_HEAD = "06bab5acd124e768f70305b3d7eca2f595871890"
FAILED_DIR = pathlib.Path("/root/newfi-run-" + FAILED_CID)
WRAPPER = FAILED_DIR / "wrapper.log"

OLD = {
    "controller": "53909e753ea5a011550fd280ad1c342a74ca9949be092dfbd95244243cae92f3",
    "templates_xml": "c29c04f8b937284e821c87957653e87c2f6ac014fac30648a0f6b18e84392adf",
}
NEW = {
    "controller": "619d489f7cc3b461479c702fa8825097ae22d3c6cfeb911e9f14ea1de9c36d6d",
    "service": "4da03f26294ab6d163a1a746dee4bb6a82d7d4f39e66b24246bc0eb4c455dd1d",
    "templates_xml": "1650c3d868d4bc47703f1b75f1f179cd25309b2d3b20a2b848e81a4423c00861",
}

INSPECT = '<?php\n$root=\'/var/www/forums/newfi/public\';\nrequire $root.\'/src/XF.php\';\n\\XF::start($root);\n$app=\\XF::setupApp(\'XF\\\\Pub\\\\App\');\n$db=$app->db();\n\nfunction meta($p) {\n    if (!is_file($p)) { return null; }\n    return [\'bytes\'=>filesize($p),\'sha256\'=>hash_file(\'sha256\',$p)];\n}\n\n$portal=$root.\'/src/addons/Newfi/Portal\';\n$baseline=(gethostname()===\'newfi-staging\') ? 40 : 0;\n\n$templates=$db->fetchAll(\n    "SELECT title,style_id,LENGTH(template) AS bytes,SHA2(template,256) AS sha256," .\n    " LOCATE(\'data-newfi-portal200\',template)>0 AS html_marker," .\n    " LOCATE(\'--newfi-portal200\',template)>0 AS css_marker" .\n    " FROM xf_template WHERE addon_id=\'Newfi/Portal\' ORDER BY style_id,title"\n);\n\n$errors=$db->fetchAll(\n    "SELECT error_id,exception_type,message,filename,line" .\n    " FROM xf_error_log WHERE error_id>? ORDER BY error_id LIMIT 30",\n    [$baseline]\n);\n\n$result=[\n    \'hostname\'=>gethostname(),\n    \'application\'=>[\n        \'boardActive\'=>(string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id=\'boardActive\'"),\n        \'boardUrl\'=>(string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id=\'boardUrl\'"),\n        \'indexRoute\'=>(string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id=\'indexRoute\'"),\n        \'error_count\'=>(int)$db->fetchOne("SELECT COUNT(*) FROM xf_error_log"),\n        \'error_max_id\'=>(int)$db->fetchOne("SELECT COALESCE(MAX(error_id),0) FROM xf_error_log")\n    ],\n    \'source\'=>[\n        \'controller\'=>meta($portal.\'/Pub/Controller/Portal.php\'),\n        \'service\'=>meta($portal.\'/Service/PortalData.php\'),\n        \'templates_xml\'=>meta($portal.\'/_data/templates.xml\')\n    ],\n    \'templates\'=>$templates,\n    \'new_errors\'=>$errors\n];\n\necho "PORTAL200_RCA_GUEST_JSON=" .\n    json_encode($result,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE) .\n    "\\n";\n?>'

def emit(k, v=None):
    if v is None:
        print(k, flush=True)
    elif isinstance(v, (dict, list, bool, int, float)):
        print(k + "=" + json.dumps(v, ensure_ascii=False, sort_keys=True, separators=(",", ":")), flush=True)
    else:
        print(k + "=" + str(v), flush=True)

def run(cmd, *, input_bytes=None, timeout=120):
    return subprocess.run(
        cmd, input=input_bytes, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
        timeout=timeout, check=False
    )

def git(*args):
    p = run(["/usr/bin/git", "-C", str(WT), *args], timeout=90)
    return p.returncode, p.stdout.decode("utf-8", "replace").strip(), p.stderr.decode("utf-8", "replace").strip()

def qga(target, php_source):
    if target == "prod":
        cmd = [
            "/usr/sbin/qm", "guest", "exec", "210",
            "--synchronous", "1", "--timeout", "120",
            "--pass-stdin", "1", "--", "/usr/bin/php8.3"
        ]
    else:
        q = [
            "/usr/sbin/qm", "guest", "exec", "211",
            "--synchronous", "1", "--timeout", "120",
            "--pass-stdin", "1", "--", "/usr/bin/php8.3"
        ]
        remote = (
            "test \"$(id -u):$(hostname -s)\" = 0:pve03 || exit 72; "
            "for f in /etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md /etc/pve/31_HOMELAB_REFERENCE.md; do "
            "test -r \"$f\" && test -s \"$f\" && cat -- \"$f\" >/dev/null || exit 73; done; exec "
            + " ".join(shlex.quote(x) for x in q)
        )
        cmd = [
            "/usr/bin/ssh", "-T",
            "-o", "BatchMode=yes",
            "-o", "StrictHostKeyChecking=yes",
            "-o", "UpdateHostKeys=no",
            "-o", "ConnectTimeout=10",
            "-o", "ConnectionAttempts=1",
            "root@pve03", remote
        ]
    p = run(cmd, input_bytes=php_source.encode("utf-8"), timeout=150)
    if p.returncode != 0:
        return {"transport_rc": p.returncode, "transport_error": p.stderr.decode("utf-8", "replace")[:500]}
    try:
        outer = json.loads(p.stdout.decode("utf-8", "replace"))
    except Exception:
        return {"transport_rc": 0, "outer_json": False, "raw": p.stdout.decode("utf-8", "replace")[:1000]}
    if outer.get("exitcode") != 0:
        return {
            "transport_rc": 0,
            "guest_rc": outer.get("exitcode"),
            "guest_out": outer.get("out-data", "")[:1000],
            "guest_err": outer.get("err-data", "")[:1000],
        }
    out = outer.get("out-data", "")
    marker = "PORTAL200_RCA_GUEST_JSON="
    line = next((x for x in out.splitlines() if x.startswith(marker)), None)
    if not line:
        return {"transport_rc": 0, "guest_rc": 0, "marker": False, "guest_out": out[:1000]}
    return json.loads(line[len(marker):])

def source_state(data):
    src = data.get("source", {})
    controller = (src.get("controller") or {}).get("sha256")
    service = src.get("service")
    templates = (src.get("templates_xml") or {}).get("sha256")
    if controller == OLD["controller"] and service is None and templates == OLD["templates_xml"]:
        return "OLD"
    if (
        controller == NEW["controller"]
        and (service or {}).get("sha256") == NEW["service"]
        and templates == NEW["templates_xml"]
    ):
        return "NEW"
    return "DRIFT"

def template_state(data):
    rows = data.get("templates", [])
    titles = [r.get("title") for r in rows]
    has_html = any(bool(r.get("html_marker")) for r in rows)
    has_css = any(bool(r.get("css_marker")) for r in rows)
    if titles == ["newfi_portal"] and not has_html and not has_css:
        return "OLD"
    if titles == ["newfi_portal", "newfi_portal.less"] and has_html and has_css:
        return "NEW"
    return "DRIFT"

emit("NEWFI39_BEGIN")
emit("COMMAND_ID", CID)
emit("RCA_TARGET_FAILED_COMMAND", FAILED_CID)
emit("PORTAL200_ROADMAP_REMAINS", 22)
emit("RCA_MUTATION_SCOPE", "NONE_READ_ONLY")

if WRAPPER.is_file() and not WRAPPER.is_symlink():
    raw = WRAPPER.read_bytes()
    emit("RCA_WRAPPER_BYTES", len(raw))
    emit("RCA_WRAPPER_SHA256", hashlib.sha256(raw).hexdigest())
    text = raw.decode("utf-8", "replace")
    keep_prefixes = (
        "CHAT_OUTPUT_BEGIN", "CHAT_OUTPUT_END", "COMMAND_ID=", "STATUS=", "RC=",
        "RESULT_CONTRACT_", "COMMAND_RC=", "CHANGES_MADE=", "ROLLBACK_",
        "MUTATION_OUTCOME=", "OUTPUT_BEGIN", "OUTPUT_END", "NEWFI200",
        "PORTAL200_", "HOMELAB_RESULT_CONTRACT=", "STOP=", "Traceback",
    )
    selected = [line for line in text.splitlines() if line.startswith(keep_prefixes)]
    emit("RCA_WRAPPER_SELECTED_LINE_COUNT", len(selected))
    print("RCA_WRAPPER_SELECTED_BEGIN", flush=True)
    for line in selected[-250:]:
        print(line, flush=True)
    print("RCA_WRAPPER_SELECTED_END", flush=True)
else:
    emit("RCA_WRAPPER_MISSING", True)

_, branch, _ = git("branch", "--show-current")
_, head, _ = git("rev-parse", "HEAD")
_, remote_out, _ = git("ls-remote", "origin", f"refs/heads/{BRANCH}")
_, status, _ = git("status", "--porcelain=v1", "--untracked-files=all")
remote = remote_out.split()[0] if remote_out else ""

git_state = {
    "branch": branch,
    "head": head,
    "remote": remote,
    "clean": (status == ""),
    "status_lines": status.splitlines()[:20],
}
emit("RCA_GIT_STATE", git_state)

staging = qga("staging", INSPECT)
prod = qga("prod", INSPECT)

emit("RCA_STAGING_STATE", staging)
emit("RCA_PROD_STATE", prod)

s_src = source_state(staging) if isinstance(staging, dict) else "UNKNOWN"
p_src = source_state(prod) if isinstance(prod, dict) else "UNKNOWN"
s_tpl = template_state(staging) if isinstance(staging, dict) else "UNKNOWN"
p_tpl = template_state(prod) if isinstance(prod, dict) else "UNKNOWN"

summary = {
    "git_old_head": head == OLD_HEAD and remote == OLD_HEAD,
    "git_clean": status == "",
    "staging_source": s_src,
    "staging_templates": s_tpl,
    "staging_error_max": staging.get("application", {}).get("error_max_id") if isinstance(staging, dict) else None,
    "prod_source": p_src,
    "prod_templates": p_tpl,
    "prod_error_max": prod.get("application", {}).get("error_max_id") if isinstance(prod, dict) else None,
}
summary["clean_old_prestate"] = bool(
    summary["git_old_head"]
    and summary["git_clean"]
    and s_src == "OLD"
    and s_tpl == "OLD"
    and p_src == "OLD"
    and p_tpl == "OLD"
)
emit("RCA_STATE_SUMMARY", summary)

emit("HOMELAB_RESULT_CONTRACT", {
    "version": 1,
    "command_id": CID,
    "status": "OK",
    "changes_made": False,
    "rollback_started": False,
    "rollback_restored": None
})
emit("NEWFI39_END")

CANDIDATE_LAYER_6_TEXT_END
RECOVERED_VERSION_IDS=
RECOVERED_VERSION_STRINGS==
RECOVERED_SHA256_COUNT=11
RECOVERED_SHA256=458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e,834973af57263b326ca6aee52b2ecb3fa2cf0fb486e5a77a62d4210395b8d9c3,28e855ff56c4844768f0e8606fe0edc2acb3f548a5c06fa15c0d80ae00e2bc1b,fc9fb1dbdb6141bfb43a8122dcf3db435a94da48d0a0c02fe9dfae019b1439c5,2f7977cbfaed7953bd70033c0cf92138eedf974f5886fadf4b4971f2d00a4143,b248a4c32c9cc64e5747e7dce6c7fc0a23f5124a77c71ce72e27a81aceae9d2d,619d489f7cc3b461479c702fa8825097ae22d3c6cfeb911e9f14ea1de9c36d6d,4da03f26294ab6d163a1a746dee4bb6a82d7d4f39e66b24246bc0eb4c455dd1d,1650c3d868d4bc47703f1b75f1f179cd25309b2d3b20a2b848e81a4423c00861,53909e753ea5a011550fd280ad1c342a74ca9949be092dfbd95244243cae92f3,c29c04f8b937284e821c87957653e87c2f6ac014fac30648a0f6b18e84392adf
RECOVERY_RESULT=STRONG_APPLY3_CANDIDATE_EVIDENCE_FOUND
LOCAL_APPLY3_CARRIER_RECOVERY5_END=true

OUTPUT_END
CHAT_OUTPUT_END
