# Дыра #2 — партиционирование 7 audit-таблиц (design)

**Версия:** v1.0 от 23.05.2026 (вечер)
**Триггер:** последняя из 7 дыр аудита журналирования. Заказчик: «делай дальше».
**План реализации:** `2026-05-23-hole-2-audit-partitioning-plan.md` (создаётся после approve этого spec).

---

## 1. Контекст

Из аудита журналирования: 7 audit-таблиц **растут вечно** на проде без механизма retention. Цель — partitioning **по месяцам** чтобы можно было `DROP` старых партиций (или `ATTACH` в архив).

### 1.1. Текущее состояние (разведка 23.05 вечер)

**Все 7 таблиц — обычные heap, БЕЗ partition** (memory была неверна):

| # | Table | partition | hash-chain | RLS | size прод | rows прод |
|---|---|---|---|---|---|---|
| 1 | `auth_log` | нет | ✅ | ✅ tenant_user (saas-admin исключён) | 112 kB | 32 |
| 2 | `activity_log` | нет | ✅ | ✅ tenant_deal_created | 200 kB | 413 |
| 3 | `tenant_operations_log` | нет | ✅ (inline) | ✅ | 104 kB | 9 |
| 4 | `webhook_log` | нет | ❌ нет | ✅ | 32 kB | 0 |
| 5 | `balance_transactions` | нет | ✅ | ✅ | 128 kB | 275 |
| 6 | `pd_processing_log` | нет | ✅ | ✅ | 56 kB | 2 |
| 7 | `saas_admin_audit_log` | нет | ✅ | ❌ saas-only | 48 kB | 0 |

**ИТОГО на проде: ~712 kB / 731 row.** Данных микро — миграция мгновенная.

### 1.2. Существующий tooling

- `App\Services\MonthlyPartitionManager::PARTITIONED_TABLES` — hardcoded whitelist на 2 таблицы (`deals`, `supplier_lead_costs`).
- `App\Console\Commands\PartitionsCreateMonths` — daily cron, `--ahead=2` месяца вперёд. Идемпотентно.
- `pg_partman` НЕ используется (был установлен на проде, но решено через app-cron).
- На dev накоплено 102 partition children для `deals`/`supplier_lead_costs`.

### 1.3. Сложности

**S1. PRIMARY KEY.** PostgreSQL требует partition-key в PK. Сейчас PK=`(id)`. После — PK=`(id, created_at)` (или `received_at` для `webhook_log`).

**S2. FK на webhook_log.** Две таблицы ссылаются на `webhook_log.id`:

- `failed_webhook_jobs.webhook_log_id` (схема L1954) — 0 строк
- `rejected_deals_log.webhook_log_id` (схема L1976) — 0 строк

В PG 16 FK на partitioned-таблицу с композитным PK невозможен **по одной колонке**. Варианты:

- (W1) **Удалить FK**, поддерживать целостность приложением (просто, минимально, 0 строк сейчас).
- (W2) Добавить `received_at`-колонку в `failed_webhook_jobs`/`rejected_deals_log` + композитный FK на `(webhook_log_id, received_at)`. Сложнее, но FK сохраняется.
- (W3) Не партиционировать `webhook_log` (нет hash-chain → нет аудиторной критичности; retention 90 дней через cleanup-job вместо partitioning).

**S3. hash-chain trigger `audit_chain_hash()`.** Использует `TG_TABLE_NAME` в SELECT prev-hash — после партиционирования `TG_TABLE_NAME` будет именем **партиции** (`auth_log_y2026_m05`), значит цепочка станет **per-partition**. Это **семантически согласуется** с уже выкаченным fix'ом hole #1 «per-RLS-scope hash-chain validation» (commits `378cfba4`/`a195611d`) — там тоже было «несколько scopes на таблицу». Валидатор `VerifyAuditChains` нужно адаптировать чтобы он сканировал партиции отдельно.

**S4. RLS на partitioned tables.** В PG 16 RLS-политики на parent НЕ наследуются партициями автоматически — нужно явно создавать на каждой партиции ИЛИ использовать parent-level policy через `ENABLE ROW LEVEL SECURITY` на parent + дублирование на партициях через cron. Это уже решено для `deals` (parent имеет RLS, partitions наследуют) — проверить в schema.sql точный паттерн.

**S5. Триггеры на partitioned tables.** `BEFORE INSERT` триггеры на parent **наследуются** в PG 16 (с версии 13+). Это работает.

**S6. Indexes.** Создаются на parent → на партициях создаются автоматически как локальные. Уникальные индексы должны включать partition-key.

---

## 2. Решение

### 2.1. Стратегия миграции

Поскольку нельзя `ALTER TABLE ... PARTITION BY`, миграция = **rewrite**:

1. Переименовать старую таблицу: `auth_log → auth_log_old`
2. Создать новую partitioned: `CREATE TABLE auth_log (...) PARTITION BY RANGE (created_at)`
3. Создать партиции для прошлого + текущего + 2 будущих месяца: `auth_log_y2026_m01 ... auth_log_y2026_m07`
4. Скопировать данные: `INSERT INTO auth_log SELECT * FROM auth_log_old`
5. Восстановить FK/RLS/triggers/indexes на новую parent
6. `DROP TABLE auth_log_old` после verify

### 2.2. Scope

**Все 7 таблиц** — данных мало (731 row total), миграция дёшева. Если ждать пока вырастут — миграция станет дороже.

**FK на webhook_log:** выбор **W1 (удалить FK)** — минимально, 0 строк сейчас, app-уровень целостности достаточен (insert в `failed_webhook_jobs` всегда сопровождается известным `webhook_log_id`, который мы только что создали в той же транзакции).

### 2.3. Retention policy (новый cron-job)

Без `DROP` старых партиций цель «не растут вечно» не достигнута. Нужен второй cron:

- **`partitions:drop-expired`** — еженедельно (Sundays 03:00 МСК).
- Per-table retention из `system_settings` (есть прецедент `webhook_log_retention_days=90`):
  - `auth_log_retention_months = 24` (152-ФЗ требует 6 лет для финансовых, но auth — security event, 2 года достаточно для расследования инцидентов)
  - `activity_log_retention_months = 36` (бизнес-журнал — 3 года для разборок)
  - `tenant_operations_log_retention_months = 24` (audit операций админа)
  - `webhook_log_retention_months = 3` (raw payloads с ПДн — короткий срок)
  - `balance_transactions_retention_months = 84` (7 лет — финансовые по ст.29 НК РФ)
  - `pd_processing_log_retention_months = 36` (152-ФЗ — 3 года минимум для PD-обработки)
  - `saas_admin_audit_log_retention_months = 84` (7 лет — действия админа = финансовые операции)

Эти значения — **дефолты**, изменяемы через `system_settings`. Drop делается атомарно (`DROP TABLE ... CASCADE` партиции — не trigger'ит каскадов на parent, RLS не теряется).

### 2.4. Hash-chain валидация после partitioning

`VerifyAuditChains` (hole #1) — обновить чтобы сканировал партиции отдельно:

- Старая логика: `SELECT MAX(id) FROM auth_log` + проверка SHA-256 цепочки от 1 до max.
- Новая логика: для каждой партиции (получить список через `pg_partitions`) — проверить цепочку внутри неё. Разрыв в одной партиции = инцидент с указанием партиции.

Это семантически совместимо с уже выкаченным fix'ом per-RLS-scope (каждая партиция = свой scope, как saas-admin/tenant scope в auth_log).

---

## 3. Фазы выкатки

### Phase A — Разработка + dev-тесты (без прод)

A.1. Spec (этот документ)
A.2. Plan (TDD-задачи)
A.3. Extension `MonthlyPartitionManager::PARTITIONED_TABLES` (whitelist +7)
A.4. Миграция rewrite для 7 таблиц (один файл, idempotent через `IF EXISTS`)
A.5. Обновить `db/schema.sql` — заменить 7 объявлений
A.6. Pest-тесты: partition создаётся / data копируется / RLS работает / FK удалён / hash-chain per-partition / `partitions:drop-expired` работает
A.7. Adapt `VerifyAuditChains` (per-partition scan) + тесты
A.8. Новая команда `partitions:drop-expired` + cron в `routes/console.php` + тесты
A.9. cspell/pint/regression

### Phase B — Прод-выкатка (с явным approve заказчика на каждом шаге)

B.1. **Полный dump БД** (`pg_dump`) → `/home/ubuntu/deploy-backups/pre-partitioning-{ts}.dump`
B.2. Verify dump: `pg_restore --list ... | wc -l` ≥ ожидаемое
B.3. Применить миграцию (одна транзакция): `BEGIN; ... 7 rewrite steps ...; COMMIT;`
B.4. Smoke: row count в каждой таблице соответствует pre-snapshot
B.5. Запустить `partitions:create-months` — должны создаться партиции на 2 месяца вперёд
B.6. Запустить `audit:verify-chains` — должна сработать новая per-partition логика, все chains intact
B.7. Watch incidents_log 1 час — нет ли спайков от партиционирования

### Phase C — Documentation

C.1. Update ПИЛОТ.md §6 п.11 — добавить под-пункт #2
C.2. Update ЭТАЛОН.md — schema v8.31
C.3. Update memory `project_7holes_audit_followup` — закрыть #2
C.4. Update master tracker overview — финал

---

## 4. Открытые вопросы для заказчика

**OQ1. Scope:** партиционировать **все 7** или меньше? Рекомендация — все 7 (данных мало, дёшево, лучше сейчас чем через год).

**OQ2. FK на webhook_log:** удалить FK (W1) или сохранить через композитный FK (W2)? Рекомендация — W1 (минимально, 0 рисков).

**OQ3. Retention defaults** (§2.3) — устраивают цифры?

- auth: 24 мес, activity: 36, tenant_ops: 24, webhook: 3, balance: 84, pd: 36, saas_admin: 84.

**OQ4. Cron расписание** для `partitions:drop-expired` — Sundays 03:00 МСК или другое?

**OQ5. Hash-chain семантика** — принять per-partition chain (рекомендация — да, совместимо с hole #1 per-scope логикой) или отказаться от partitioning hash-chain таблиц?

---

## 5. Риски

| Риск | Вероятность | Митигация |
|---|---|---|
| Потеря данных при rewrite | низкая | dump до миграции + транзакция + verify counts |
| Hash-chain разрыв | средняя | per-partition scope (семантически OK); validator adapt |
| RLS не наследуется | низкая | проверка паттерна на `deals` (уже работает) |
| FK ломает существующий код | низкая | grep `webhook_log_id` использования; 0 строк сейчас |
| Cron not running после deploy | низкая | smoke `schedule:list` + `scheduler_heartbeats` следит |
| Долгая блокировка таблиц | очень низкая | данных <1MB, миграция <1сек |
