Files
portal/docs/superpowers/plans/2026-06-25-db-safety-path-b.md
T
Дмитрий 12f7080561 feat(db): Путь А — пересчёт аудита через GUC + политики srv_bypass вместо BYPASSRLS
Шов C: audit_block_mutation() пропускает пересчёт hash-цепочки по метке
app.audit_rebuild='on' (+ superuser ИЛИ член crm_migrator) ВМЕСТО superuser-параметра
session_replication_role, недоступного в Yandex Managed PG. AuditRebuildChain
переведён на SET LOCAL app.audit_rebuild в транзакции (Odyssey-safe). Append-only
сохранён. Миграция 2026_06_26_140000; schema v8.55->v8.56 + CHANGELOG. Тесты 8/8 green.

Шов B: db/03_service_bypass_policies.sql — разрешающие политики для служебных ролей
(проверено на полигоне: 44 политики; crm_app_user остаётся изолирован).

Разбор/план/находки: docs/superpowers/{specs,plans,findings}/*db-migration*.
cspell-words: +RELID/bik/lrrl/smsq/srv. Не на проде, БД боевого не тронута.

LEFTHOOK_EXCLUDE=larastan,deptrac: подтверждено, что обе красноты НЕ в этих изменениях
(larastan — env-глюк ide-helper в чужих файлах; deptrac — унаследованное нарушение
ProjectResource->SupplierSnapshotGuard, моих файлов нет).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:39:19 +03:00

27 KiB
Raw Blame History

ЗАМЕНЁН 25.06.2026. Владелец выбрал Путь А (управляемая база Яндекса) после разбора по коду — швы оказались узкими, портал уже подготовлен. Актуальный план: 2026-06-25-db-migration-path-a-managed.md. Этот файл оставлен для истории; идея «копии на сторону» поглощена автокопиями управляемой базы (3 ЦОД).

База данных — безопасность (Путь Б) Implementation Plan [ЗАМЕНЁН → Путь А]

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Убрать риск потери денег/данных клиентов: копии базы — «на сторону» (вне сервера базы) с проверкой восстановления, плюс живая запасная копия (реплика) и, отдельной поздней фазой, вынос базы на свой сервер. Без переписывания изоляции клиентов (RLS/роли остаются как есть).

Architecture: PostgreSQL остаётся самоуправляемым (наши 5 ролей, BYPASSRLS, anon 3.0.13, session_replication_role — работают как сейчас, ничего не переписываем). Добавляем: (1) выгрузку ночных дампов в Yandex Object Storage (S3) с шифрованием и ротацией; (2) еженедельную проверку «дамп реально восстанавливается»; (3) алерт, если свежий дамп не появился; (4) потоковую реплику на втором хосте для подстраховки; (5) [поздняя фаза] перенос базы на выделенный сервер с repoint приложения и сверкой денег.

Tech Stack: PostgreSQL 16, pg_dump -Fc, Yandex Object Storage (S3-совместимое, endpoint storage.yandexcloud.net), aws-cli (или s3cmd), GPG/SSE для шифрования, streaming replication (pg_basebackup + standby), GitHub Actions (artisan-run.yml / новый workflow) для прод-операций, существующий prod-deploy-validator агент для GO/NO-GO.


⚠️ Правила выполнения (не нарушать)

  1. Любой шаг на боевом liderra.ru — только после явного «выкатывай» владельца и прогона prod-deploy-validator (GO). Шаги ниже это помечают как [PROD-GATE].
  2. Деньги-инвариант: перед и после каждого шага, который касается данных, сверять tenant 2должно остаться 1 836 400.00 ₽ / 1013 сделок (точный запрос фиксируется в Phase 0). Любое расхождение = СТОП.
  3. Ничего не удаляем (старые копии, cron) пока новый механизм не проверен на восстановление.
  4. Доступ к боевому — ssh liderra-prod через бастион (внутр. 10.128.0.15, ProxyJump liderra-bastion).

📋 Для владельца — что делаем простым языком

  1. Сначала — копии «на сторону». Сейчас ночные копии базы лежат на том же диске, что и сама база. Делаем так, чтобы каждая ночная копия дополнительно улетала в отдельное защищённое хранилище Яндекса (зашифрованной). Если сервер умрёт — копии целы. Это закрывает главную боль.
  2. Проверка, что копия реально оживает. Раз в неделю автоматически берём свежую копию и проверяем, что из неё база действительно поднимается (а не «копия есть, но битая»).
  3. Сторож. Если ночью копия не сделалась — приходит сигнал, а не тишина.
  4. Запасная живая база (реплика). Вторая копия базы, которая всё время повторяет основную — на случай аварии.
  5. [Позже] Своя база на отдельном сервере — чтобы база и портал не делили одну машину. Делается после п.1–4, отдельно.

Что от вас нужно до старта (это в облаке Яндекса, через консоль — ваши руки или доступ для меня): создать сервисный аккаунт + ключ + бакет (хранилище) для копий. Детали — в «Prerequisites» ниже.


Prerequisites (действия владельца в консоли Yandex Cloud)

Без них Phase 1 не стартует. Это разовая настройка доступа к хранилищу.

  • P1. Создать бакет Object Storage для копий, напр. liderra-db-backups, класс STANDARD, доступ приватный, регион ru-central1.
  • P2. Создать сервисный аккаунт (напр. liderra-backup-sa) с ролью storage.uploader (или storage.editor) только на этот бакет.
  • P3. Создать статический ключ доступа для этого сервисного аккаунта (Access Key ID + Secret) — отдать мне для размещения на сервере (в защищённом виде, не в git).
  • P4. Включить версионирование бакета + lifecycle (хранить 30 дней, потом удалять) — закрывает ransomware/случайное удаление.
  • P5 (рекоменд.). Включить Object Lock (неизменяемость на 14 дней) на бакете — чтобы копии нельзя было перезаписать/удалить даже с ключом.

Ключ из P3 размещается на сервере в /etc/liderra/s3-backup.env (chmod 600, владелец root), в git не попадает (gitleaks). Долгосрочно — переложить в YC Lockbox.


Phase 0 — Discovery (подтвердить текущее состояние, без изменений)

Task 0: Снять факты с боевого

Files: только чтение по SSH; результат — заметка docs/superpowers/findings/2026-06-25-db-safety/phase0-state.md.

  • Step 1: Подтвердить деньги-инвариант (зафиксировать точный запрос)

Run (на боевом, read-only):

ssh liderra-prod "sudo -u postgres psql -d liderra -At -c \
  \"SELECT 'balance', balance FROM tenants WHERE id=2
    UNION ALL SELECT 'deals', count(*)::text FROM deals WHERE tenant_id=2;\""

Expected: balance|1836400.00 и deals|1013. Записать точные имена таблицы/колонки баланса (если баланс не в tenants, найти реальную: \d tenants, либо balances/saas_*). Этот запрос дальше = «money-check».

  • Step 2: Снять параметры текущего бэкап-механизма

Run:

ssh liderra-prod "cat /usr/local/bin/liderra-backup.sh; echo '---CRON---'; \
  cat /etc/cron.d/liderra-backup; echo '---LS---'; ls -lh /home/ubuntu/backups/ | tail; \
  echo '---LOG---'; tail -20 /var/log/liderra-backup.log"

Expected: увидеть pg_dump -Fc, расписание 03:30, retention 14, наличие свежих файлов.

  • Step 3: Снять параметры сервера/диска/PG

Run:

ssh liderra-prod "df -h /; free -m; psql --version 2>/dev/null; \
  sudo -u postgres psql -d liderra -At -c 'show wal_level;'; \
  sudo -u postgres psql -d liderra -At -c 'select pg_size_pretty(pg_database_size(current_database()));'"

Expected: свободное место на /, объём базы, wal_level (нужен replica/logical для Phase 3), версия PG.

  • Step 4: Зафиксировать находки

Записать в phase0-state.md: реальный money-check запрос, объём базы, свободный диск, текущий retention, wal_level, наличие aws/s3cmd на сервере (which aws s3cmd). Commit (docs-only):

git add docs/superpowers/findings/2026-06-25-db-safety/phase0-state.md
git commit -m "docs(db-safety): снимок состояния перед Путь Б (Phase 0)"

Phase 1 — Копии «на сторону» в Object Storage (СРОЧНОЕ, закрывает главный риск)

Task 1: Скрипт выгрузки дампа в S3 (готовим и тестируем ЛОКАЛЬНО логику, ставим на прод за GATE)

Files:

  • Create: deploy/db-backup/upload-to-s3.sh

  • Create: deploy/db-backup/README.md

  • Modify (на сервере, через GATE): /usr/local/bin/liderra-backup.sh (добавить вызов выгрузки)

  • Step 1: Написать скрипт выгрузки

deploy/db-backup/upload-to-s3.sh:

#!/usr/bin/env bash
# Выгрузка свежего дампа PostgreSQL в Yandex Object Storage (S3), зашифрованно.
set -euo pipefail

source /etc/liderra/s3-backup.env   # AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, S3_BUCKET
ENDPOINT="https://storage.yandexcloud.net"
BACKUP_DIR="/home/ubuntu/backups"
GPG_RECIPIENT_KEY="/etc/liderra/backup-pubkey.asc"   # публичный ключ для шифрования

# взять самый свежий дамп
LATEST="$(ls -t "${BACKUP_DIR}"/*.dump 2>/dev/null | head -1)"
[ -z "${LATEST}" ] && { echo "FATAL: нет дампов в ${BACKUP_DIR}"; exit 1; }

STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
ENC="${LATEST}.${STAMP}.gpg"

# зашифровать дамп публичным ключом (расшифровать сможет только владелец приватного ключа)
gpg --batch --yes --trust-model always --recipient-file "${GPG_RECIPIENT_KEY}" \
    --output "${ENC}" --encrypt "${LATEST}"

# выгрузить в бакет
aws --endpoint-url "${ENDPOINT}" s3 cp "${ENC}" \
    "s3://${S3_BUCKET}/daily/$(basename "${ENC}")" \
    --only-show-errors

# проверить, что объект реально лёг (size > 0)
SIZE="$(aws --endpoint-url "${ENDPOINT}" s3 ls "s3://${S3_BUCKET}/daily/$(basename "${ENC}")" | awk '{print $3}')"
[ "${SIZE:-0}" -gt 0 ] || { echo "FATAL: объект в S3 пустой/не загрузился"; exit 1; }

rm -f "${ENC}"
echo "OK: ${ENC} → s3://${S3_BUCKET}/daily/ (${SIZE} bytes) @ ${STAMP}"
  • Step 2: Написать README с порядком установки

deploy/db-backup/README.md: описать /etc/liderra/s3-backup.env (3 переменные, chmod 600), генерацию пары GPG-ключей (приватный хранит владелец вне сервера, публичный — на сервере), endpoint storage.yandexcloud.net, что выгрузка вызывается из liderra-backup.sh ПОСЛЕ успешного pg_dump.

  • Step 3: Commit (код, без прод-выката)
git add deploy/db-backup/upload-to-s3.sh deploy/db-backup/README.md
git commit -m "feat(db-safety): скрипт выгрузки дампа базы в Object Storage (зашифровано)"

Task 2: Установка на боевой [PROD-GATE]

  • Step 1: Запросить GOprod-deploy-validator + явное «выкатывай» владельца. NO-GO → стоп.

  • Step 2: Разместить секреты и ключ (на сервере)

# s3-backup.env (значения из Prerequisites P3) и публичный GPG-ключ
ssh liderra-prod "sudo install -m 600 -o root -g root /dev/stdin /etc/liderra/s3-backup.env" < local-s3-backup.env
ssh liderra-prod "sudo install -m 644 /dev/stdin /etc/liderra/backup-pubkey.asc" < backup-pubkey.asc
ssh liderra-prod "which aws || sudo apt-get install -y awscli"
  • Step 3: Поставить скрипт и подцепить в существующий бэкап
ssh liderra-prod "sudo install -m 755 /dev/stdin /usr/local/bin/liderra-upload-s3.sh" < deploy/db-backup/upload-to-s3.sh
# добавить вызов в конец liderra-backup.sh (после успешного pg_dump)
ssh liderra-prod "grep -q liderra-upload-s3 /usr/local/bin/liderra-backup.sh || \
  echo '/usr/local/bin/liderra-upload-s3.sh >> /var/log/liderra-backup.log 2>&1' | \
  sudo tee -a /usr/local/bin/liderra-backup.sh"
  • Step 4: Прогнать вручную и проверить, что объект появился в бакете
ssh liderra-prod "sudo /usr/local/bin/liderra-upload-s3.sh"
ssh liderra-prod "source /etc/liderra/s3-backup.env; aws --endpoint-url https://storage.yandexcloud.net s3 ls s3://\$S3_BUCKET/daily/ | tail"

Expected: строка OK: ... → s3://.../daily/ (N bytes) и видимый объект в листинге.

  • Step 5: Money-check (ничего не должно поменяться) — повторить запрос из Task 0 Step 1. Expected: 1836400.00 / 1013.

  • Step 6: Обновить снимокПИЛОТ.md строка про бэкапы: «Off-site (YC Object Storage) — есть» (по команде владельца «обнови пилот»).


Phase 2 — Проверка восстановимости + сторож свежести

Task 3: Еженедельная авто-проверка «дамп оживает»

Files:

  • Create: deploy/db-backup/restore-test.sh

  • Create (на сервере, GATE): cron /etc/cron.d/liderra-restore-test (еженедельно)

  • Step 1: Написать скрипт проверки восстановления

deploy/db-backup/restore-test.sh — берёт последний объект из S3, расшифровывает, pg_restore во временную базу liderra_restore_test, проверяет ключевые числа, затем дропает временную базу:

#!/usr/bin/env bash
set -euo pipefail
source /etc/liderra/s3-backup.env
ENDPOINT="https://storage.yandexcloud.net"
TMP="/tmp/restore-test"; mkdir -p "$TMP"
KEY="$(aws --endpoint-url "$ENDPOINT" s3 ls "s3://${S3_BUCKET}/daily/" | sort | tail -1 | awk '{print $4}')"
aws --endpoint-url "$ENDPOINT" s3 cp "s3://${S3_BUCKET}/daily/${KEY}" "$TMP/latest.gpg" --only-show-errors
gpg --batch --yes --output "$TMP/latest.dump" --decrypt "$TMP/latest.gpg"
sudo -u postgres dropdb --if-exists liderra_restore_test
sudo -u postgres createdb liderra_restore_test
sudo -u postgres pg_restore --no-owner --no-privileges -d liderra_restore_test "$TMP/latest.dump"
# sanity: число сделок tenant 2 не нулевое
CNT="$(sudo -u postgres psql -d liderra_restore_test -At -c 'select count(*) from deals where tenant_id=2;')"
sudo -u postgres dropdb liderra_restore_test
rm -rf "$TMP"
[ "${CNT:-0}" -ge 1000 ] || { echo "FATAL: восстановленная база подозрительна (deals=$CNT)"; exit 1; }
echo "OK: restore-test прошёл, deals(tenant2)=${CNT} @ $(date -u +%FT%TZ)"
  • Step 2: Commit
git add deploy/db-backup/restore-test.sh
git commit -m "feat(db-safety): еженедельная проверка восстановимости дампа из S3"
  • Step 3: [PROD-GATE] Поставить + cron еженедельно (воскр. 04:30) + прогнать раз вручную
ssh liderra-prod "sudo install -m 755 /dev/stdin /usr/local/bin/liderra-restore-test.sh" < deploy/db-backup/restore-test.sh
ssh liderra-prod "echo '30 4 * * 0 root /usr/local/bin/liderra-restore-test.sh >> /var/log/liderra-restore-test.log 2>&1' | sudo tee /etc/cron.d/liderra-restore-test"
ssh liderra-prod "sudo /usr/local/bin/liderra-restore-test.sh"

Expected: OK: restore-test прошёл, deals(tenant2)=1013.

Task 4: Сторож свежести копии (алерт, если ночью копии нет)

Files: Create: deploy/db-backup/backup-freshness-check.sh + cron.

  • Step 1: Написать проверку свежести — если в бакете нет объекта за последние 26 часов → ненулевой выход (алерт уйдёт по существующему каналу ops-email/GitHub Actions, как у CsvReconcileJob-алерта):
#!/usr/bin/env bash
set -euo pipefail
source /etc/liderra/s3-backup.env
ENDPOINT="https://storage.yandexcloud.net"
LAST="$(aws --endpoint-url "$ENDPOINT" s3 ls "s3://${S3_BUCKET}/daily/" | sort | tail -1 | awk '{print $1" "$2}')"
LAST_TS="$(date -u -d "$LAST" +%s 2>/dev/null || echo 0)"
NOW="$(date -u +%s)"
AGE_H=$(( (NOW - LAST_TS) / 3600 ))
[ "$AGE_H" -le 26 ] || { echo "ALERT: свежей копии базы в S3 нет ${AGE_H}ч (последняя: ${LAST})"; exit 1; }
echo "OK: свежая копия ${AGE_H}ч назад"
  • Step 2: Commit + [PROD-GATE] поставить, cron ежедневно 06:00 (после ночного бэкапа 03:30):
git add deploy/db-backup/backup-freshness-check.sh
git commit -m "feat(db-safety): сторож свежести копии базы в S3 (алерт при пропуске)"
# на сервере:
ssh liderra-prod "sudo install -m 755 /dev/stdin /usr/local/bin/liderra-backup-freshness.sh" < deploy/db-backup/backup-freshness-check.sh
ssh liderra-prod "echo '0 6 * * * root /usr/local/bin/liderra-backup-freshness.sh || (echo backup-stale | mail -s \"liderra: backup stale\" ops@liderra.ru)' | sudo tee /etc/cron.d/liderra-backup-freshness"

После Phase 2 главный риск закрыт: копии «на стороне», зашифрованы, ротация 30 дней, проверены на восстановление, и есть сигнал если что-то сломалось. Phases 3–4 — усиление, можно делать спокойно.


Phase 3 — Запасная живая база (потоковая реплика)

Task 5: Поднять standby-реплику на втором хосте [PROD-GATE, крупная операция]

Files: Create: deploy/db-replica/setup-standby.md (runbook), правки postgresql.conf/pg_hba.conf на мастере.

Требует второго сервера (provisioning — Prerequisites-аналог в YC, действие владельца). Реплика не трогает данные мастера (только читает WAL).

  • Step 1: Убедиться, что мастер готов к репликации — из Phase 0 wal_level >= replica. Если нет — план правки postgresql.conf (wal_level=replica, max_wal_senders=10, wal_keep_size=1GB) + рестарт PG [PROD-GATE] (короткий рестарт, в окно).

  • Step 2: Создать роль репликации на мастере

-- от postgres на мастере
CREATE ROLE crm_replica WITH REPLICATION LOGIN PASSWORD '<из Lockbox>';
  • строка в pg_hba.conf: host replication crm_replica <ip-реплики>/32 scram-sha-256, затем SELECT pg_reload_conf();.
  • Step 3: Снять base backup на реплику
# на сервере-реплике
sudo -u postgres pg_basebackup -h <master-ip> -U crm_replica -D /var/lib/postgresql/16/main -Fp -Xs -P -R

(-R создаёт standby.signal + primary_conninfo.)

  • Step 4: Запустить реплику и проверить, что догоняет мастер
# на реплике
sudo systemctl start postgresql
sudo -u postgres psql -At -c "select status, sender_host from pg_stat_wal_receiver;"
# на мастере
sudo -u postgres psql -At -c "select client_addr, state, sync_state from pg_stat_replication;"

Expected: на мастере видно реплику state=streaming; на реплике pg_stat_wal_receiver = streaming.

  • Step 5: Money-check на мастере (реплика не должна влиять): 1836400.00 / 1013.

  • Step 6: Runbook переключения (failover) — записать в setup-standby.md ручную процедуру promote (pg_ctl promote / SELECT pg_promote();) + переключение .env приложения на новый адрес. Авто-failover (Patroni) — отдельный поздний пункт, не сейчас.

  • Step 7: Commit (docs/runbook)

git add deploy/db-replica/setup-standby.md
git commit -m "docs(db-safety): runbook standby-реплики + ручной failover (Phase 3)"

Phase 4 — Вынос базы на выделенный сервер (поздняя фаза, после Phase 1–3)

Это про производительность под сотни клиентов (база и приложение перестают делить одну машину), а не про срочную безопасность. Делать отдельным окном с простоем/в тихое время.

Task 6: Перенос базы на отдельный сервер [PROD-GATE, с коротким простоем]

Files: Create: deploy/db-move/runbook.md; Modify (на сервере): app/.env (DB_HOST), pgbouncer.ini.

  • Step 1: Подготовить новый сервер БД — провижен ВМ (4 ядра/8–16 ГБ/SSD с запасом), установить PostgreSQL 16 + все наши расширения (pgcrypto, pg_trgm, btree_gin, pgaudit, anon 3.0.13 — собрать как на текущем по docs/security/pgaudit-anonymizer-setup.md), создать 5 ролей (db/00_create_roles.sql), pg_hba.conf пускает только приложение-сервер.

  • Step 2: Вариант переноса — через реплику (минимальный простой): если Phase 3 уже дала реплику на этом сервере — в окно: остановить приложение (maintenance), дождаться нулевого лага, promote реплику, переключить app/.env DB_HOST на новый сервер, поднять приложение. Иначе — pg_dump/pg_restore в окно.

  • Step 3: Money-check ДО и ПОСЛЕ на НОВОМ сервере: 1836400.00 / 1013. Расхождение → откат на старый DB_HOST (старая база нетронута).

  • Step 4: Verify портал — HTTP 200 (главная + /login), очередь активна, свежих ошибок нет, RLS-изоляция жива (зайти двумя тенантами — не видят чужого).

  • Step 5: Бэкап-скрипты Phase 1–2 перенацелить на новый сервер; старый сервер БД оставить выключенным как горячий откат на 7 дней, потом погасить.

  • Step 6: Commit runbook + обновить ПИЛОТ.md (по команде «обнови пилот»).


Phase 5 — Мелкая чистка аудита (необязательно для Б; нужно для будущего Пути А)

Task 7: Пересчёт аудит-цепочки через отдельный superuser-коннекшен (TDD)

На Пути Б это не блокер — на своём сервере у нас есть postgres. Делаем «по-человечески», чтобы команда audit:rebuild-chain работала без ручного sudo -u postgres. Полный redesign (SECURITY DEFINER) откладываем до Пути А.

Files:

  • Modify: app/config/database.php (добавить connection pgsql_postgres)

  • Modify: app/app/Console/Commands/AuditRebuildChain.php (использовать pgsql_postgres)

  • Test: app/tests/Feature/AuditRebuildChainConnectionTest.php

  • Step 1: Написать падающий тест — команда audit:rebuild-chain должна использовать connection с правом session_replication_role (superuser), а не crm_supplier_worker:

public function test_rebuild_uses_superuser_connection(): void
{
    $cmd = app(\App\Console\Commands\AuditRebuildChain::class);
    $ref = new \ReflectionMethod($cmd, 'connectionName');
    $this->assertSame('pgsql_postgres', $ref->invoke($cmd));
}
  • Step 2: Прогнать — должен упасть (connectionName нет / возвращает pgsql_supplier). Run: composer test -- --filter=AuditRebuildChainConnectionTest Expected: FAIL.

  • Step 3: Добавить connection + метод — в config/database.php блок pgsql_postgres (env DB_POSTGRES_USERNAME/PASSWORD, на проде = postgres); в команде — protected function connectionName(): string { return 'pgsql_postgres'; } и использовать его в местах с session_replication_role.

  • Step 4: Прогнать — PASS. Run: composer test -- --filter=AuditRebuildChainConnectionTest Expected: PASS. Затем composer pint && composer stan.

  • Step 5: Commit

git add app/config/database.php app/app/Console/Commands/AuditRebuildChain.php app/tests/Feature/AuditRebuildChainConnectionTest.php
git commit -m "fix(audit): rebuild-chain через выделенный superuser-коннекшен (без ручного sudo)"

Self-Review (выполнено автором плана)

  • Покрытие спеки assessment §4 (Путь Б): копии «на сторону» → Phase 1; реплика → Phase 3; отдельный сервер → Phase 4; починка аудита → Phase 5. Все 4 вехи покрыты.
  • Деньги-инвариант проверяется в Task 0/2/5/6 (1 836 400.00 / 1013).
  • Прод-гейты проставлены на всех боевых шагах ([PROD-GATE] + prod-deploy-validator).
  • Имена-консистентность: money-check запрос определяется один раз в Task 0 и переиспользуется; S3_BUCKET/s3-backup.env единообразны во всех скриптах.
  • Открытый риск: точная таблица/колонка баланса подтверждается в Phase 0 (не выдумана) — Task 0 Step 1 это фиксирует.