Files
portal/docs/superpowers/plans/2026-06-25-db-migration-path-a-managed.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

389 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Переезд базы на управляемую Yandex (Путь А) 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:** Перевести боевую базу liderra.ru на **Yandex Managed Service for PostgreSQL** так, чтобы Яндекс сам делал копии/восстановление/отказоустойчивость, БЕЗ переписывания логики портала. Изоляцию клиентов и аудит сохраняем, переложив их с «особых прав ролей» (BYPASSRLS / session_replication_role, которых на управляемой базе нет) на штатные механизмы PostgreSQL (разрешающие RLS-политики + GUC-метка).
**Architecture:** Меняется СЛОЙ РОЛЕЙ/ПОЛИТИК БД, не код портала. Три служебные роли теряют атрибут BYPASSRLS → вместо него получают разрешающие политики `FOR ALL TO <role> USING(true)`. Пересчёт аудит-цепочки перестаёт отключать триггеры через superuser-параметр → триггер пропускает себя по GUC-метке `app.audit_rebuild`. Маскировка ПДн переходит на встроенную в управляемую базу anon 1.3.2. Всё проверяется на **тест-кластере (копии)** до боевого переезда; данные мигрируют дамп/restore (база ~13 МБ → окно простоя минуты).
**Tech Stack:** PostgreSQL 16, Yandex Managed Service for PostgreSQL (роли `mdb_superuser`/`mdb_admin`, пулер Odyssey порт 6432, SSL verify-full + CA), Laravel 13 (`config/database.php`, Pest), `pg_dump -Fc`/`pg_restore`, anon 1.3.2 (built-in), `rls-reviewer` агент, `prod-deploy-validator` агент.
---
> 🔄 **Обновлено 26.06.2026 под правки прод-сессии (проверено по коду до `7efe9e3e`):** схема v8.53→**v8.55** (+2 SaaS-таблицы `supplier_deferred_sync`, `supplier_sync_runs` — без RLS, переносятся дампом как есть, нужен только GRANT `crm_supplier_worker`, уже в blanket-гранте). Дамп для переезда берём из **текущего** прода (там уже live разблокировка смены источника + маршрутизация по снимку). `SharesSupplierPdo` — тестовая механика, прогон тестов на тест-кластере (Phase 5) должен её учитывать. Деньги-инвариант — волатилен (см. правило 3).
## ⚠️ Правила выполнения (не нарушать)
1. **Вся переделка — сперва на ТЕСТ-КЛАСТЕРЕ (копии).** Боевую базу не трогаем, пока изоляция и деньги не подтверждены на копии.
2. **Боевой переезд — только после явного «выкатывай» владельца** + `prod-deploy-validator` (GO). Помечено `[PROD-GATE]`.
3. **Деньги-инвариант (ВОЛАТИЛЕН — сверять снимок ДО==ПОСЛЕ одной операции, НЕ с фиксированным числом):** портал живой, числа растут каждый день. На 26.06.2026 прод = `tenant 2 ≈ 1 838 400 ₽ / 1016 сделок (990 живых + 26 удал.) / 999 731 лид` (было 1 836 400 / 1013 на 25.06). Точный текущий запрос и значение фиксируются в Phase 0 непосредственно перед операцией; критерий — равенство снимков до и после, а не совпадение с историческим числом.
4. **Изоляция:** после смены модели — обязательный прогон `rls-reviewer` + тест «клиент A не видит данных клиента B» на копии. Любая утечка = СТОП.
5. **Старую базу не гасим** ≥7 дней после переезда (горячий откат: вернуть `DB_HOST`).
---
## 📋 Для владельца — что делаем простым языком
1. Берём у Яндекса **управляемую базу** — сначала **тестовую копию**, чтобы всё проверить, не трогая боевую.
2. На копии меняем «замки»: служебные роли получают доступ через правила вместо особого права. Портал при этом не переписываем.
3. Проверяем на копии, что **клиенты не видят чужого** и **деньги целы**.
4. Налаживаем маскировку персональных данных встроенным средством Яндекса.
5. В тихое окно (простой — минуты) **переключаем боевой портал** на управляемую базу. Старую держим неделю на всякий случай.
6. Дальше Яндекс сам делает копии, запасную базу и переключение — **ручного нянченья больше нет**.
**Что от вас нужно:** в консоли Яндекса создать тестовый и боевой кластеры + 5 пользователей базы (могу провести по шагам или с доступом сделать сам). Детали — «Prerequisites».
---
## Prerequisites (действия владельца в консоли Yandex Cloud)
- [ ] **P1. Тест-кластер** Managed PostgreSQL 16, 1 хост (network-ssd), БД `liderra`, в той же сети `ru-central1`. Для проверок HA не нужен.
- [ ] **P2. 5 пользователей БД** через консоль (БЕЗ возможности BYPASSRLS — её там и нет): `crm_app_user`, `crm_admin_user`, `crm_migrator`, `crm_audit_writer`, `crm_supplier_worker`. Пароли — в Lockbox.
- [ ] **P3. CA-сертификат** кластера (`https://storage.yandexcloud.net/cloud-certs/CA.pem`) — для шифрованного подключения.
- [ ] **P4. Включить расширения** на кластере (консоль/CLI): `pgcrypto`, `pg_trgm`, `btree_gin`, `pgaudit`, `anon` (1.3.2) — через Shared preload libraries + список расширений БД.
- [ ] **P5. Боевой кластер** Managed PostgreSQL 16, **HA: ≥2 хоста** (мастер + кворумная реплика), хранение копий 30 дней, PITR включён. Создаётся ПЕРЕД Phase 6, не раньше.
> Пароли/ключи — в YC Lockbox, в `.env` сервера и `app/.env.production`; в git не попадают (gitleaks).
---
## Phase 0 — Discovery + точный money-check
### Task 0: Снять факты с боевого (read-only)
**Files:** заметка `docs/superpowers/findings/2026-06-25-db-migration/phase0.md`.
- [ ] **Step 1: Зафиксировать money-check (точные таблица/колонка)**
Run:
```bash
ssh liderra-prod "sudo -u postgres psql -d liderra -At -c \
\"SELECT 'balance', balance::text FROM tenants WHERE id=2
UNION ALL SELECT 'deals', count(*)::text FROM deals WHERE tenant_id=2;\""
```
Expected: число порядка `balance|1838400.00`, `deals|1016` (волатильно — на 26.06; зафиксировать **актуальное** значение как эталон «ДО» для сверки «ПОСЛЕ»). Если баланс не в `tenants` — найти реальную таблицу (`\d tenants`), записать точный запрос как «money-check». Добавить также проверку лидов (`999 731` на 26.06) и новых SaaS-таблиц `supplier_deferred_sync`/`supplier_sync_runs` (есть в схеме v8.55).
- [ ] **Step 2: Список RLS-таблиц и держателей BYPASSRLS (для шва B)**
Run:
```bash
ssh liderra-prod "sudo -u postgres psql -d liderra -At -c \
\"SELECT tablename FROM pg_tables WHERE schemaname='public' AND rowsecurity ORDER BY 1;\"; \
echo '---ROLES---'; sudo -u postgres psql -d liderra -At -c \
\"SELECT rolname FROM pg_roles WHERE rolbypassrls AND rolcanlogin ORDER BY 1;\""
```
Expected: ~36 таблиц; роли с BYPASSRLS = `crm_admin_user`, `crm_migrator`, `crm_supplier_worker` (подтвердить, что ровно эти три).
- [ ] **Step 3: Объём базы + версия + anon** для оценки переноса.
```bash
ssh liderra-prod "sudo -u postgres psql -d liderra -At -c \
\"select pg_size_pretty(pg_database_size('liderra'));\"; \
sudo -u postgres psql -d liderra -At -c \"select default_version from pg_available_extensions where name='anon';\""
```
- [ ] **Step 4: Commit (docs-only)**
```bash
git add docs/superpowers/findings/2026-06-25-db-migration/phase0.md
git commit -m "docs(db-migration): снимок боевого перед Путь А (Phase 0)"
```
---
## Phase 1 — Модель безопасности на тест-кластере (швы A + B)
> Цель: на копии воспроизвести изоляцию БЕЗ BYPASSRLS и доказать, что она держит.
### Task 1: Залить схему+данные на тест-кластер
**Files:** runbook `deploy/db-migration/01-load-test-cluster.md`.
- [ ] **Step 1: Снять свежий дамп боевого** (read-only):
```bash
ssh liderra-prod "sudo -u postgres pg_dump -Fc -d liderra -f /tmp/liderra-$(date -u +%Y%m%d).dump" && \
ssh liderra-prod "cat /tmp/liderra-*.dump" > /tmp/liderra.dump
```
- [ ] **Step 2: Restore в тест-кластер под владельцем** (`mdb_superuser`-пользователь кластера):
```bash
pg_restore --no-owner --no-privileges --no-acl \
-h <TEST_FQDN> -p 6432 -U <admin_user> -d liderra \
"sslmode=verify-full sslrootcert=~/.postgresql/root.crt" /tmp/liderra.dump
```
- [ ] **Step 3: Money-check на тест-кластере** — точный запрос из Task 0. Expected: `1836400.00 / 1013`. Расхождение → разобраться до продолжения.
- [ ] **Step 4: Commit runbook**
```bash
git add deploy/db-migration/01-load-test-cluster.md
git commit -m "docs(db-migration): загрузка тест-кластера из дампа боевого"
```
### Task 2: Разрешающие политики для служебных ролей (замена BYPASSRLS)
**Files:**
- Create: `db/migrations/2026_06_26_service_role_bypass_policies.sql`
- Create: `app/database/migrations/2026_06_26_000001_service_role_bypass_policies.php` (обёртка, выполняет .sql)
- Test: `app/tests/Feature/Db/ServiceRoleBypassPolicyTest.php`
- [ ] **Step 1: Написать падающий тест изоляции/обхода**
`ServiceRoleBypassPolicyTest.php` — два утверждения: (1) `crm_app_user` с tenant-контекстом видит только своего; (2) служебная роль (эмуляция: разрешающая политика присутствует) видит cross-tenant. На тест-БД (sqlite/pg) проверяем через наличие политики `srv_bypass`:
```php
public function test_service_bypass_policies_exist_on_all_rls_tables(): void
{
$rls = DB::select("SELECT tablename FROM pg_tables WHERE schemaname='public' AND rowsecurity");
foreach ($rls as $t) {
$pol = DB::select(
"SELECT 1 FROM pg_policies WHERE tablename = ? AND policyname = 'srv_bypass'",
[$t->tablename]
);
$this->assertNotEmpty($pol, "Нет srv_bypass на {$t->tablename}");
}
}
```
- [ ] **Step 2: Прогнать — упадёт** (политик ещё нет).
Run: `composer test -- --filter=ServiceRoleBypassPolicyTest`
Expected: FAIL.
- [ ] **Step 3: Написать SQL-миграцию (идемпотентную)**
`db/migrations/2026_06_26_service_role_bypass_policies.sql`:
```sql
-- Заменяет атрибут BYPASSRLS на разрешающие политики для 3 служебных ролей.
-- crm_app_user НЕ получает политику → остаётся tenant-isolated.
-- crm_audit_writer НЕ получает (append-only, без bypass).
DO $$
DECLARE t record;
BEGIN
FOR t IN SELECT tablename FROM pg_tables
WHERE schemaname='public' AND rowsecurity
LOOP
EXECUTE format('DROP POLICY IF EXISTS srv_bypass ON public.%I;', t.tablename);
EXECUTE format(
'CREATE POLICY srv_bypass ON public.%I AS PERMISSIVE FOR ALL
TO crm_admin_user, crm_migrator, crm_supplier_worker
USING (true) WITH CHECK (true);', t.tablename);
END LOOP;
END $$;
```
Laravel-обёртка `2026_06_26_000001_service_role_bypass_policies.php``DB::unprepared(file_get_contents(base_path('../db/migrations/2026_06_26_service_role_bypass_policies.sql')))` + запись в `db/CHANGELOG_schema.md`.
- [ ] **Step 4: Применить на тест-кластер + прогнать тест — PASS**
```bash
psql -h <TEST_FQDN> -p 6432 -U <admin_user> -d liderra -f db/migrations/2026_06_26_service_role_bypass_policies.sql
composer test -- --filter=ServiceRoleBypassPolicyTest
```
Expected: политики на всех ~36 таблицах; тест PASS.
- [ ] **Step 5: Применить GRANT'ы (адаптированный 02_grants под владельца) + сменить пароли/роли на тест-кластере** (`db/02_grants.sql`, запуск под `mdb_superuser`-пользователем вместо postgres).
- [ ] **Step 6: Commit**
```bash
git add db/migrations/2026_06_26_service_role_bypass_policies.sql app/database/migrations/2026_06_26_000001_service_role_bypass_policies.php app/tests/Feature/Db/ServiceRoleBypassPolicyTest.php db/CHANGELOG_schema.md
git commit -m "feat(db): разрешающие политики служебных ролей вместо BYPASSRLS (Managed PG)"
```
### Task 3: Проверка изоляции на тест-кластере (живая)
- [ ] **Step 1: Подключиться как `crm_app_user`** (не-bypass) к тест-кластеру, поставить контекст tenant 2, проверить, что видны ТОЛЬКО его сделки:
```bash
psql -h <TEST_FQDN> -p 6432 -U crm_app_user -d liderra "sslmode=verify-full sslrootcert=~/.postgresql/root.crt" \
-c "BEGIN; SET LOCAL app.current_tenant_id = 2; SELECT count(*) FROM deals; \
SET LOCAL app.current_tenant_id = 999; SELECT count(*) FROM deals; ROLLBACK;"
```
Expected: для tenant 2 — 1013; для несуществующего 999 — 0. (Изоляция держит без BYPASSRLS.)
- [ ] **Step 2: Подключиться как `crm_supplier_worker`** — убедиться, что cross-tenant виден (разрешающая политика работает): `SELECT count(DISTINCT tenant_id) FROM deals;` > 1.
- [ ] **Step 3: Прогнать `rls-reviewer`** на схему с новыми политиками. Вердикт без orphan/leak. Если замечания — исправить и повторить Task 2.
---
## Phase 2 — Пересчёт аудита без session_replication_role (шов C, TDD)
### Task 4: GUC-метка вместо отключения триггеров
**Files:**
- Modify: `db/schema.sql` (функция `audit_block_mutation`, ~строка 3320)
- Modify: `app/app/Console/Commands/AuditRebuildChain.php:107,137`
- Test: `app/tests/Feature/AuditRebuildChainTest.php`
- [ ] **Step 1: Написать падающий тест** — пересчёт цепочки в партиции проходит без `session_replication_role`, при выставленном `app.audit_rebuild='on'`, и аудит остаётся append-only без метки:
```php
public function test_rebuild_allowed_only_with_guc_flag(): void
{
// без флага — UPDATE log_hash блокируется триггером
$this->expectException(\Illuminate\Database\QueryException::class);
DB::statement("UPDATE activity_log SET log_hash = log_hash WHERE id = (SELECT min(id) FROM activity_log)");
}
public function test_rebuild_passes_with_guc(): void
{
DB::transaction(function () {
DB::statement("SET LOCAL app.audit_rebuild = 'on'");
DB::statement("UPDATE activity_log SET log_hash = log_hash WHERE id = (SELECT min(id) FROM activity_log)");
});
$this->assertTrue(true); // не упало
}
```
- [ ] **Step 2: Прогнать — упадёт** (триггер пока знает только session_replication_role).
Run: `composer test -- --filter=AuditRebuildChainTest`
Expected: FAIL на `test_rebuild_passes_with_guc` (триггер блокирует UPDATE).
- [ ] **Step 3: Изменить функцию `audit_block_mutation()`**`db/schema.sql` + миграция):
```sql
CREATE OR REPLACE FUNCTION audit_block_mutation() RETURNS trigger AS $$
BEGIN
-- Разрешить пересчёт цепочки доверенным процессом по метке (вместо superuser-параметра).
IF current_setting('app.audit_rebuild', true) = 'on'
AND current_user IN ('crm_migrator','crm_admin_user') THEN
RETURN NEW;
END IF;
RAISE EXCEPTION 'audit table is append-only (%.%)', TG_TABLE_SCHEMA, TG_TABLE_NAME;
END;
$$ LANGUAGE plpgsql;
```
Запись в `db/CHANGELOG_schema.md`.
- [ ] **Step 4: Изменить `AuditRebuildChain.php`** — обернуть пересчёт в `DB::connection('pgsql_supplier')->transaction(...)` и внутри `SET LOCAL app.audit_rebuild = 'on'` вместо строк 107/137 (`session_replication_role`). Убрать сброс на `'origin'` (SET LOCAL сам сбрасывается в конце транзакции; это ещё и Odyssey-safe).
- [ ] **Step 5: Прогнать — PASS** + `composer pint && composer stan`.
Run: `composer test -- --filter=AuditRebuildChainTest`
Expected: PASS оба теста.
- [ ] **Step 6: Commit**
```bash
git add db/schema.sql app/app/Console/Commands/AuditRebuildChain.php app/tests/Feature/AuditRebuildChainTest.php db/CHANGELOG_schema.md
git commit -m "fix(audit): пересчёт цепочки через GUC-метку app.audit_rebuild (без superuser)"
```
> Историческая миграция `2026_05_23_hole2_partition_audit_tables.sql:34` (тоже `session_replication_role`) при переезде дамп/restore НЕ перезапускается — данные переносятся как есть. Future-proof правка той миграции — follow-up, не блокер.
---
## Phase 3 — Маскировка ПДн на встроенной anon (шов D)
### Task 5: Применить метки маскировки на тест-кластере
**Files:** `db/anon_masking_labels.sql` (метки уже написаны), runbook `deploy/db-migration/03-masking.md`.
- [ ] **Step 1: Включить anon на кластере** (Prerequisite P4) и применить метки:
```bash
psql -h <TEST_FQDN> -p 6432 -U <admin_user> -d liderra -c "LOAD 'anon';" -f db/anon_masking_labels.sql
```
Если какая-то функция отсутствует в 1.3.2 — заменить на доступный аналог (`partial`, `partial_email`, `fake_first_name/last_name`, `MASKED WITH VALUE NULL` — стандартные, ожидаются присутствующими).
- [ ] **Step 2: Проверить правила маскирования**
```bash
psql ... -c "SELECT relname, attname, masking_function FROM anon.pg_masking_rules ORDER BY 1,2;"
```
Expected: ~16 правил (users/deals/supplier_leads/pd_*/auth_log/tenants), как в `anon_masking_labels.sql`.
- [ ] **Step 3: Тест маскированного дампа** — снять дамп ролью с меткой MASKED, убедиться, что телефоны/почта замаскированы, а `tenant_id`/ключи целы; money-check на восстановленном из маскированного дампа НЕ обязан совпадать по ПДн, но числа сделок целы.
- [ ] **Step 4: Commit runbook**
```bash
git add deploy/db-migration/03-masking.md
git commit -m "docs(db-migration): маскировка ПДн на встроенной anon (тест-кластер)"
```
---
## Phase 4 — Подключение приложения (шов: SSL + Odyssey)
### Task 6: Конфиг подключения к управляемой базе
**Files:** Modify: `app/config/database.php` (опции SSL), `app/.env.production` (значения).
- [ ] **Step 1: Написать падающий тест конфига**`pgsql` connection отдаёт `sslmode=verify-full` и `sslrootcert`, когда заданы env:
```php
public function test_pgsql_uses_verify_full_when_configured(): void
{
config(['database.connections.pgsql.sslmode' => 'verify-full',
'database.connections.pgsql.sslrootcert' => '/etc/liderra/yc-ca.pem']);
$this->assertSame('verify-full', config('database.connections.pgsql.sslmode'));
$this->assertNotNull(config('database.connections.pgsql.sslrootcert'));
}
```
- [ ] **Step 2: Прогнать — упадёт** (нет ключа `sslrootcert`).
Run: `composer test -- --filter=pgsql_uses_verify_full`
Expected: FAIL.
- [ ] **Step 3: Добавить опции в `$pgsqlConnection`** (`config/database.php:22`):
```php
'sslmode' => env('DB_SSLMODE', 'prefer'),
'sslrootcert' => env('DB_SSLROOTCERT'), // путь к CA.pem управляемой базы
```
(`pgsql_supplier` наследует через `array_merge` — править не нужно.)
- [ ] **Step 4: Прогнать — PASS.**
Run: `composer test -- --filter=pgsql_uses_verify_full`
Expected: PASS.
- [ ] **Step 5: Подготовить `.env.production` значения** (НЕ коммитить секреты): `DB_HOST=<FQDN кластера>`, `DB_PORT=6432` (Odyssey), `DB_SSLMODE=verify-full`, `DB_SSLROOTCERT=/etc/liderra/yc-ca.pem`, `DB_SUPPLIER_USERNAME=crm_supplier_worker`. Положить CA на сервер.
- [ ] **Step 6: Commit (код, без секретов)**
```bash
git add app/config/database.php
git commit -m "feat(db): SSL verify-full + CA для подключения к управляемой базе"
```
---
## Phase 5 — Полный прогон на копии (генеральная репетиция)
### Task 7: Прогнать портал против тест-кластера
- [ ] **Step 1:** Поднять приложение (локально/стейдж) с `.env`, указывающим на тест-кластер (port 6432, verify-full).
- [ ] **Step 2: Регрессия**`composer test` (Pest, backend+billing+RLS), Vitest. Expected: зелено (RLS/изоляция/деньги).
- [ ] **Step 3: Живой смоук** — вход клиентом, открыть сделки (видит свои), приём тестового лида через `pgsql_supplier`-путь (cross-tenant запись проходит), пересчёт аудита `audit:rebuild-chain` на партиции (работает через GUC), `audit:verify-chains` intact.
- [ ] **Step 4: Money-check** на тест-кластере: `1836400.00 / 1013`.
- [ ] **Step 5:** Зафиксировать результат репетиции в `docs/superpowers/findings/2026-06-25-db-migration/dry-run.md` + commit (docs-only).
---
## Phase 6 — Боевой переезд `[PROD-GATE]`
### Task 8: Переключение боевого на управляемый кластер (окно, простой минуты)
**Files:** runbook `deploy/db-migration/06-cutover.md`.
- [ ] **Step 1: Запросить GO**`prod-deploy-validator` + явное «выкатывай». NO-GO → стоп.
- [ ] **Step 2:** Создать боевой кластер (Prerequisite P5, HA ≥2 хоста), накатить роли/политики/grants/маскировку как на тесте (всё уже отлажено).
- [ ] **Step 3: Окно обслуживания** — включить maintenance на портале (очередь на паузу).
- [ ] **Step 4: Финальный дамп боевого → restore в боевой кластер**:
```bash
ssh liderra-prod "sudo -u postgres pg_dump -Fc -d liderra" > /tmp/final.dump
pg_restore --no-owner --no-privileges -h <PROD_FQDN> -p 6432 -U <admin_user> -d liderra "sslmode=verify-full sslrootcert=..." /tmp/final.dump
```
- [ ] **Step 5:** Применить политики `srv_bypass` + grants + метки маскировки на боевом кластере.
- [ ] **Step 6: Money-check на новом боевом кластере**`1836400.00 / 1013`. Расхождение → НЕ переключать, разобраться.
- [ ] **Step 7:** Переключить `app/.env` на сервере: `DB_HOST=<PROD_FQDN>`, `DB_PORT=6432`, SSL verify-full, CA. `php artisan config:cache` (от www-data, квирк 107), снять maintenance, перезапустить очередь.
- [ ] **Step 8: Verify** — HTTP 200 (главная+`/login`), вход клиентом видит свои сделки (изоляция), приём тестового лида, очередь активна, 0 свежих ошибок, `audit:verify-chains` intact, money-check ещё раз.
- [ ] **Step 9: Commit runbook + обновить `ПИЛОТ.md`** (по команде «обнови пилот»): база = управляемая, копии/HA — Яндекс.
---
## Phase 7 — Завершение
### Task 9: Декомиссия и нормативка
- [ ] **Step 1:** Старую самоуправляемую базу держать выключенной как откат **≥7 дней**, затем погасить (по команде владельца).
- [ ] **Step 2:** Снять с сервера старый cron бэкапов/реплику Пути Б — больше не нужны (копии у Яндекса). Скрипты `deploy/db-backup/*` пометить устаревшими.
- [ ] **Step 3:** Синк нормативки (`normative-sync` агент): `00_create_roles.sql` (модель без BYPASSRLS), CLAUDE.md §2 (база = Managed PG, без BYPASSRLS), память. Через положенные каналы (CLAUDE.md — плагин).
- [ ] **Step 4:** Закрыть в памяти переход (Путь А done).
---
## Self-Review (выполнено автором плана)
- **Покрытие швов AF (assessment §3a):** A,B → Task 2; C → Task 4; D → Task 5; E → Task 2 Step 5; F (partition DDL) — наследует роль-владельца кластера, отдельной правки кода не требует (DDL уже через `pgsql_supplier`/migrator). ✅
- **App-код не переписываем:** подтверждено — меняются только `config/database.php` (SSL, Task 6) и `AuditRebuildChain.php` (Task 4). ~30 джобов на `pgsql_supplier` и ~50 мест `app.current_tenant_id` — НЕ трогаются. ✅
- **Изоляция проверена дважды:** Task 3 (живой тест app_user vs supplier_worker) + Task 7 Step 2 (regression RLS) + `rls-reviewer`. ✅
- **Деньги-инвариант** в Task 0/1/5/7/8 (1 836 400.00 / 1013). ✅
- **Прод-гейты** на всех боевых шагах (Phase 6). ✅
- **Имена-консистентность:** политика `srv_bypass`, метка `app.audit_rebuild`, 5 ролей, порт 6432 — единообразны во всех задачах. ✅
- **Открытое (честно):** точная таблица баланса и парность функций anon 1.3.2 подтверждаются в Phase 0/3 на копии, не выдуманы. Историческая миграция с `session_replication_role` — follow-up (не блокер при дамп/restore). ✅