# ADR-018: Audit hash-chain semantics — per-tenant (через RLS scope) canonical

- **Status:** Accepted
- **Date:** 2026-05-29
- **Deciders:** User: Дмитрий (business policy 152-ФЗ)

## Context

Портал ведёт 6 append-only audit-таблиц с криптографической SHA-256 hash-chain
для tamper-detection (требование 152-ФЗ ст.18 ч.2):
`auth_log`, `activity_log`, `tenant_operations_log`, `pd_processing_log`,
`saas_admin_audit_log`, `balance_transactions`. Каждая запись содержит
`log_hash = sha256(prev_log_hash || ROW(...)::text)`, где `prev_log_hash`
берётся из последней предыдущей записи. UPDATE/DELETE заблокированы
триггером `audit_block_mutation` ([db/schema.sql:3134-3138](../../db/schema.sql#L3134)).

Все 6 таблиц партиционированы по месяцам (RANGE по `created_at`).

**Инцидент 29.05.2026** (`docs/incidents/2026-05-29-disk-full-pg-recovery.md`):
переполнение диска вызвало race condition в trigger `audit_chain_hash()` —
часть concurrent INSERT'ов создали ветвление цепочки. Был выпущен migration
`2026_05_30_000001_add_advisory_lock_to_audit_chain_hash.php` с
`pg_advisory_xact_lock`, race закрыт. Затем запущена команда `audit:rebuild-chain`
для пересчёта повреждённых партиций.

После rebuild `audit:verify-chains` показал:

- `balance_transactions_y2026_m05` — 0 mismatches ✅
- `activity_log_y2026_m05` — 6 mismatches остаются (multi-tenant rows)

При анализе обнаружилась несогласованность между тремя местами кода, которые
работают с цепочкой:

| Место | Файл | Семантика |
|---|---|---|
| **Writer** (trigger) | [db/schema.sql:3107-3127](../../db/schema.sql#L3107) `audit_chain_hash()` | `SELECT log_hash FROM <partition> ORDER BY id DESC LIMIT 1` под RLS вставляющей сессии — **видит только rows своего tenant'а** (для tenant-таблиц), то есть фактически **per-tenant chain** |
| **Verify** | [app/app/Console/Commands/VerifyAuditChains.php:130-146](../../app/app/Console/Commands/VerifyAuditChains.php#L130) | `LAG(log_hash) OVER (PARTITION BY tenant_id ORDER BY id)` — корректно воспроизводит per-tenant scope триггера |
| **Rebuild** | [app/app/Console/Commands/AuditRebuildChain.php:135-180](../../app/app/Console/Commands/AuditRebuildChain.php#L135) | `SET session_replication_role=replica` + global `ORDER BY id` без PARTITION BY — **не воспроизводит RLS scope**, делает global chain |

Writer и Verify согласованы по per-tenant семантике (через RLS на стороне БД).
Rebuild делает global chain — это **bug**, потому что он запускается под
admin-сессией без RLS-контекста tenant'а и не воспроизводит реальную логику
триггера. 6 mismatches в `activity_log_y2026_m05` — следствие неправильного
rebuild'а, не оригинальной порчи.

`saas_admin_audit_log` и `auth_log` пишутся всегда под BYPASSRLS-ролями
(saas-admin INSERT'ы / pre-auth INSERT'ы) — для них trigger даёт global chain
внутри партиции, и `VerifyAuditChains` использует `partition: ''` (без
PARTITION BY) — это согласовано, mismatches там нет.

ADR-002 (multi-tenancy через PostgreSQL RLS) — основа: tenant-данные изолируются
по `tenant_id` через row-level security. Audit-цепочка наследует ту же
изоляцию автоматически, потому что SELECT в trigger подпадает под RLS.

## Decision

**Canonical semantics audit hash-chain — per-tenant внутри партиции** (через
RLS scope для tenant-таблиц, global для BYPASSRLS-таблиц), как уже работают
trigger (writer) и `VerifyAuditChains`. Команда `AuditRebuildChain` —
**bug**, должна быть переписана для воспроизведения per-tenant scope при
пересчёте.

Конкретно:

1. **Writer (trigger `audit_chain_hash()`) — без изменений.** Он уже даёт
   правильную семантику автоматически через RLS scope.

2. **Verify (`VerifyAuditChains::TABLE_CONFIG`) — без изменений.** Текущий
   конфиг корректно отражает реальность: per-tenant для tenant-таблиц,
   global для admin/auth-таблиц.

3. **Rebuild (`AuditRebuildChain`) — переделать.** Команда должна обходить
   партицию **per-partition-key** (то же `partition_clause` что в
   `VerifyAuditChains::TABLE_CONFIG`):
   - для `activity_log` / `tenant_operations_log` / `balance_transactions` /
     `pd_processing_log` — отдельный rebuild для каждого `tenant_id`;
   - для `saas_admin_audit_log` / `auth_log` — global rebuild как сейчас.

4. **Очистка 6 mismatches в `activity_log_y2026_m05`** — после фикса
   rebuild'а: re-run `audit:rebuild-chain --partition=activity_log_y2026_m05`
   на dev → smoke → на проде. mismatches исчезнут (rebuild начнёт писать
   ту же per-tenant логику что trigger).

## Alternatives Considered

### Alternative A: Per-tenant canonical (выбрано)

Фиксируется как описано выше. Trigger и verify уже работают так — нужно
только починить rebuild.

**Decision Maker's reasoning (Дмитрий):** «Закон о персональных данных
требует изолированность журналов клиентов. Простота кода — слабее
требование.»

**Pros:**

- Соответствует 152-ФЗ ст.18 — журналы tenant'ов изолированы.
- Cross-tenant tampering обнаружится: если кто-то полезет в БД руками
  и подменит запись tenant'а A, цепочка tenant'а A треснет, цепочка
  tenant'а B останется intact.
- Минимальные изменения: только rebuild переделать (полдня-день кода).
- Не требует миграции БД — existing rows уже правильные.
- 6 mismatches исчезнут автоматически после re-run исправленного rebuild'а.

**Cons:**

- Rebuild сложнее: нужен цикл по `DISTINCT tenant_id` с отдельной
  prev-hash chain для каждого.

### Alternative B: Global canonical (отклонено)

Переписать trigger на `SECURITY DEFINER BYPASSRLS` чтобы он всегда видел все
rows партиции. Verify изменить — убрать `PARTITION BY tenant_id`. Rebuild
остаётся как сейчас (global).

**User Feedback:** отклонено — ослабляет 152-ФЗ и требует рискованной миграции.

**Pros:**

- Код проще: один путь во всех трёх местах.
- Rebuild не трогаем.

**Cons:**

- 152-ФЗ слабее: один tenant теоретически (через будущий баг) может повлиять
  на chain другого tenant'а.
- Требуется миграция: rebuild **всей** existing БД журналов под новую
  логику. Высокий риск операции на проде.
- Триггер становится `SECURITY DEFINER` — повышает attack surface.

### Alternative C: Do nothing

Оставить 6 mismatches как known historical gap, документировать в README,
закрыть incident.

**Pros:**

- 0 работы.

**Cons:**

- Каждый запуск `audit:verify-chains` будет писать incident (best-effort
  dedup 24ч смягчает, но не отменяет).
- Email-алёрты на `kdv1@bk.ru` каждый день после первого истекания dedup'а.
- При следующей аварии rebuild снова создаст новые mismatches — проблема
  накапливается.
- Не закрывает архитектурную несогласованность: писатель и читатель
  работают по одной логике, чинитель — по другой.

## Consequences

**Benefits**

- 152-ФЗ tamper-detection работает по полной: per-tenant изоляция аудита.
- Все три места кода (writer / verify / rebuild) консистентны по
  семантике после фикса.
- 6 mismatches в `activity_log_y2026_m05` исчезнут.
- Документирована causality между ADR-002 (RLS multi-tenancy) и
  audit-chain semantics.

**Trade-offs**

- `AuditRebuildChain` усложняется: 50-100 LOC (цикл по tenant_id, per-tenant
  prev-hash).
- Время rebuild'а партиции на много-tenant таблицах увеличивается
  пропорционально числу tenant'ов (но rebuild — операция аварийного
  восстановления, не hot path).

**Risks and mitigations**

- *Risk:* в `AuditRebuildChain` появятся пограничные случаи (tenant_id IS
  NULL, single-tenant rows). *Mitigation:* TDD-тесты на каждый шаблон —
  pure-tenant / mixed-tenant / single-row партиции; покрытие в
  `AuditRebuildChainTest.php`.
- *Risk:* `auth_log` (BYPASSRLS, global) — rebuild должен явно различать
  global vs per-tenant tables. *Mitigation:* читать `partition_clause` из
  shared конфига (extract из `VerifyAuditChains::TABLE_CONFIG` в общий
  helper), не дублировать список.
- *Risk:* при будущем добавлении 7-й audit-таблицы — забыть указать
  partition_clause. *Mitigation:* shared `AuditChainConfig::TABLES` constant
  - assertion в `VerifyAuditChains::handle()` что все 6 таблиц зарегистрированы.

## Related Decisions

- **ADR-002 (Multi-tenancy через PostgreSQL RLS)** — основа: RLS scope, через
  который trigger автоматически получает per-tenant chain semantics для
  tenant-таблиц.
- **Incident 2026-05-29 disk-full PG recovery** —
  `docs/incidents/2026-05-29-disk-full-pg-recovery.md` — контекст обнаружения
  расхождения.
- **F1 advisory-lock migration** —
  `app/database/migrations/2026_05_30_000001_add_advisory_lock_to_audit_chain_hash.php`
  закрывает race condition между concurrent INSERT'ами; работает в любой
  семантике (global или per-tenant), потому что lock ставится по
  `(TG_TABLE_NAME, tenant_id)`-ключу.

## References

- [db/schema.sql:3107-3127](../../db/schema.sql#L3107) — `audit_chain_hash()` trigger function
- [db/schema.sql:3148-3188](../../db/schema.sql#L3148) — 6 пар триггеров (BEFORE INSERT + UPDATE/DELETE block)
- [app/app/Console/Commands/VerifyAuditChains.php](../../app/app/Console/Commands/VerifyAuditChains.php) — verify command (per-tenant + global)
- [app/app/Console/Commands/AuditRebuildChain.php](../../app/app/Console/Commands/AuditRebuildChain.php) — rebuild command (bug: global only)
- [app/database/migrations/2026_05_30_000001_add_advisory_lock_to_audit_chain_hash.php](../../app/database/migrations/2026_05_30_000001_add_advisory_lock_to_audit_chain_hash.php) — F1 advisory-lock
- Memory: `memory/feedback_audit_chain_algorithm_divergence.md` — устаревшая трактовка как «divergence design'а», скорректировано в этом ADR как bug rebuild'а
- 152-ФЗ ст.18 ч.2 — требование фиксации операций обработки ПДн
- Stage 5 follow-up plan — будет создан под реализацию решения (TBD после Stage 5 batch-переключения)

## Enforcement

```json
{
  "rules": [
    {
      "id": "rebuild-must-use-shared-config",
      "description": "AuditRebuildChain должна читать partition_clause из AuditChainConfig — не определять semantics локально",
      "applies_to": ["app/app/Console/Commands/AuditRebuildChain.php"],
      "require_pattern": "AuditChainConfig::TABLES|AuditChainConfig::rowExpression"
    },
    {
      "id": "verify-must-use-shared-config",
      "description": "VerifyAuditChains должна читать TABLES из AuditChainConfig — не дублировать private const",
      "applies_to": ["app/app/Console/Commands/VerifyAuditChains.php"],
      "require_pattern": "AuditChainConfig::TABLES|AuditChainConfig::rowExpression"
    }
  ],
  "llm_judge": false
}
```

Декларативные правила активированы после Tasks 2 и 4 этого плана.
