# Audit Rebuild Per-Tenant Fix Implementation Plan

> ✅ **РЕАЛИЗОВАНО и НА ПРОДЕ** (подтверждено `app/app/Console/Commands/AuditRebuildChain.php:~112` локально и в развёрнутом коде боевого, проверка 22.06.2026) — per-tenant rebuild через `PARTITION BY tenant_id`: при `$partitionClause === 'PARTITION BY tenant_id'` команда итерирует по `tenant_id` (строки ~113-129), вызывая `rebuildScope()` отдельно на каждый tenant; BYPASSRLS-таблицы (auth_log, saas_admin_audit_log) обрабатываются global scope. Ядро ADR-018 реализовано.
>
> **NB по деталям:**
> - Реализация per-scope использует **итеративный PHP-loop `rebuildScope()`** (prev_hash propagate forward через каждую строку), а не SQL `LAG OVER`, как предлагалось в Task 4 ниже. Семантика per-tenant та же; статус под-задач Task 1..8 ниже **не верифицирован построчно** против кода — общий статус: **ядро реализовано**, детали оставлены как есть.
> - **Чистка 6 mismatches на проде** (`activity_log_y2026_m05`, Task 7) — прод-операция, **по коду не подтверждается**; статус не помечается как выполненный.

> **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:** Переписать `AuditRebuildChain` так, чтобы он воспроизводил per-tenant scope триггера `audit_chain_hash()` (как уже делает `VerifyAuditChains`) — закрывает ADR-018 и устраняет 6 mismatches в `activity_log_y2026_m05`.

**Architecture:** Извлечь `TABLE_CONFIG` (columns + partition_clause) из `VerifyAuditChains` в shared `App\Services\Audit\AuditChainConfig` (single source of truth для writer/verify/rebuild). Переписать SQL rebuild'а через `LAG OVER ({partition_clause} ORDER BY id)` — симметрично verify. Никаких изменений в trigger (`audit_chain_hash()`) и `VerifyAuditChains::TABLE_CONFIG` не нужно.

**Tech Stack:** PHP 8.3 / Laravel 13 / PostgreSQL 16 / Pest 4 / `pgsql_supplier` BYPASSRLS-роль.

**Spec source:** [docs/adr/ADR-018-audit-chain-per-tenant-semantics.md](../../adr/ADR-018-audit-chain-per-tenant-semantics.md) (Accepted 2026-05-29, Decision Maker: User: Дмитрий).

---

## File Structure

**Create:**
- `app/app/Services/Audit/AuditChainConfig.php` — shared конфиг 6 audit-таблиц (columns + partition_clause). Public const `TABLES`. Helper `rowExpression(string $table): string` для построения `ROW(...)` выражения.
- `app/tests/Unit/Services/Audit/AuditChainConfigTest.php` — unit-тесты на конфиг (полнота 6 таблиц, корректность ROW expression).
- `docs/incidents/2026-06-XX-activity-log-y2026-m05-cleanup-handoff.md` — handoff для прод-выкатки финального cleanup'а (Task 7).

**Modify:**
- `app/app/Console/Commands/VerifyAuditChains.php:98-238` — заменить private `TABLE_CONFIG` const на чтение из `AuditChainConfig::TABLES`. Поведение не меняется (regression-safe refactor).
- `app/app/Console/Commands/AuditRebuildChain.php:40-218` — заменить private `COLUMN_CONFIG` на `AuditChainConfig`, переписать `handle()` SQL под per-partition_clause logic (через `LAG OVER`).
- `app/tests/Feature/Audit/AuditRebuildChainTest.php` — добавить 3 новых сценария (multi-tenant / BYPASSRLS table / single-row partition); существующие тесты должны продолжать проходить.
- `docs/adr/ADR-018-audit-chain-per-tenant-semantics.md:230-245` — обновить Enforcement-блок: rule `rebuild-must-use-shared-config` активируется declarative regex `App\\\\Services\\\\Audit\\\\AuditChainConfig::TABLES` в `AuditRebuildChain.php`.

---

### Task 1: Создать shared AuditChainConfig

**Files:**
- Create: `app/app/Services/Audit/AuditChainConfig.php`
- Test: `app/tests/Unit/Services/Audit/AuditChainConfigTest.php`

- [ ] **Step 1: Написать failing test**

Create `app/tests/Unit/Services/Audit/AuditChainConfigTest.php`:

```php
<?php

declare(strict_types=1);

use App\Services\Audit\AuditChainConfig;

it('exposes all 6 audit tables', function (): void {
    expect(array_keys(AuditChainConfig::TABLES))->toEqual([
        'auth_log',
        'activity_log',
        'tenant_operations_log',
        'balance_transactions',
        'pd_processing_log',
        'saas_admin_audit_log',
    ]);
});

it('activity_log uses PARTITION BY tenant_id', function (): void {
    expect(AuditChainConfig::TABLES['activity_log']['partition'])
        ->toEqual('PARTITION BY tenant_id');
});

it('auth_log and saas_admin_audit_log use global chain (empty partition)', function (): void {
    expect(AuditChainConfig::TABLES['auth_log']['partition'])->toEqual('');
    expect(AuditChainConfig::TABLES['saas_admin_audit_log']['partition'])->toEqual('');
});

it('rowExpression builds ROW(...) with NULL::bytea at __log_hash__ position', function (): void {
    $expr = AuditChainConfig::rowExpression('activity_log');
    expect($expr)->toEqual(
        'ROW(t.id, t.tenant_id, t.user_id, t.deal_id, t.event, t.old_value, '
        .'t.new_value, t.context, t.ip_address, t.user_agent, NULL::bytea, t.created_at)'
    );
});

it('rowExpression throws on unknown table', function (): void {
    AuditChainConfig::rowExpression('unknown_table');
})->throws(InvalidArgumentException::class);
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd app && ./vendor/bin/pest tests/Unit/Services/Audit/AuditChainConfigTest.php`
Expected: 5 failures (class `App\Services\Audit\AuditChainConfig` not found).

- [ ] **Step 3: Создать класс с константой и helper'ом**

Create `app/app/Services/Audit/AuditChainConfig.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Audit;

use InvalidArgumentException;

/**
 * Shared конфиг hash-chain для 6 audit-таблиц.
 *
 * Single source of truth для writer (db/schema.sql trigger audit_chain_hash() — через RLS),
 * verify (App\Console\Commands\VerifyAuditChains) и rebuild
 * (App\Console\Commands\AuditRebuildChain).
 *
 * Канонический выбор семантики — ADR-018 (per-tenant через RLS scope для
 * tenant-таблиц, global для BYPASSRLS-таблиц).
 *
 * columns: список в порядке ordinal_position из db/schema.sql.
 *   '__log_hash__' — маркер позиции log_hash → NULL::bytea в ROW().
 *
 * partition: SQL-фрагмент для OVER (PARTITION BY … ORDER BY id),
 *   воспроизводящий RLS-scope триггера.
 *   '' = глобальная цепочка внутри партиции (для BYPASSRLS-таблиц).
 */
final class AuditChainConfig
{
    /**
     * @var array<string, array{columns: list<string>, partition: string}>
     */
    public const TABLES = [
        'auth_log' => [
            'columns' => [
                'id', 'actor_type', 'tenant_id', 'user_id', 'saas_admin_user_id',
                'email', 'event', 'ip_address', 'user_agent', 'failure_reason',
                '__log_hash__', 'created_at',
            ],
            'partition' => '',
        ],
        'activity_log' => [
            'columns' => [
                'id', 'tenant_id', 'user_id', 'deal_id', 'event',
                'old_value', 'new_value', 'context', 'ip_address', 'user_agent',
                '__log_hash__', 'created_at',
            ],
            'partition' => 'PARTITION BY tenant_id',
        ],
        'tenant_operations_log' => [
            'columns' => [
                'id', 'tenant_id', 'user_id', 'entity_type', 'entity_id',
                'event', 'payload_before', 'payload_after', 'ip_address', 'user_agent',
                '__log_hash__', 'created_at',
            ],
            'partition' => 'PARTITION BY tenant_id',
        ],
        'balance_transactions' => [
            'columns' => [
                'id', 'tenant_id', 'type', 'amount_rub', 'amount_leads',
                'balance_rub_after', 'balance_leads_after', 'description',
                'related_type', 'related_id', 'user_id', 'admin_user_id',
                '__log_hash__', 'created_at',
            ],
            'partition' => 'PARTITION BY tenant_id',
        ],
        'pd_processing_log' => [
            'columns' => [
                'id', 'tenant_id', 'subject_type', 'subject_id', 'action',
                'purpose', 'actor_tenant_user_id', 'actor_admin_user_id', 'ip_address',
                '__log_hash__', 'created_at',
            ],
            'partition' => 'PARTITION BY tenant_id',
        ],
        'saas_admin_audit_log' => [
            'columns' => [
                'id', 'admin_user_id', 'action', 'target_type', 'target_id',
                'target_tenant_id', 'payload_before', 'payload_after', 'reason',
                'ip_address', 'user_agent', 'requires_approval', 'approved_by', 'approved_at',
                '__log_hash__', 'created_at',
            ],
            'partition' => '',
        ],
    ];

    /**
     * Строит ROW(col1, col2, …, NULL::bytea, …, coln) с NULL::bytea на позиции log_hash.
     *
     * Пример для activity_log:
     *   ROW(t.id, t.tenant_id, …, NULL::bytea, t.created_at)
     *
     * @throws InvalidArgumentException если table не зарегистрирован в TABLES
     */
    public static function rowExpression(string $table): string
    {
        if (! isset(self::TABLES[$table])) {
            throw new InvalidArgumentException(
                "Table '{$table}' не зарегистрирована в AuditChainConfig::TABLES"
            );
        }

        $parts = [];
        foreach (self::TABLES[$table]['columns'] as $col) {
            $parts[] = ($col === '__log_hash__') ? 'NULL::bytea' : "t.{$col}";
        }

        return 'ROW('.implode(', ', $parts).')';
    }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd app && ./vendor/bin/pest tests/Unit/Services/Audit/AuditChainConfigTest.php`
Expected: 5 passed.

- [ ] **Step 5: Commit**

```bash
git add app/app/Services/Audit/AuditChainConfig.php app/tests/Unit/Services/Audit/AuditChainConfigTest.php
git commit -m "feat(audit): extract AuditChainConfig shared TABLE config (ADR-018 prep)"
```

---

### Task 2: Перевести VerifyAuditChains на shared config (regression-safe refactor)

**Files:**
- Modify: `app/app/Console/Commands/VerifyAuditChains.php:96-238` (заменить private const на чтение `AuditChainConfig::TABLES`)
- Test: `app/tests/Feature/Audit/AuditChainRaceConditionTest.php` (existing — должен продолжать проходить)

- [ ] **Step 1: Запустить полный pre-refactor regression**

Run: `cd app && ./vendor/bin/pest tests/Feature/Audit/`
Expected: all existing audit tests PASS (record baseline count, например «42 passed, 0 failed»).

- [ ] **Step 2: Заменить private TABLE_CONFIG на чтение из AuditChainConfig**

В `app/app/Console/Commands/VerifyAuditChains.php`:

(a) Удалить весь блок `private const TABLE_CONFIG = [ … ];` (строки 96-238 текущей версии).

(b) В начале файла добавить `use App\Services\Audit\AuditChainConfig;`.

(c) В `handle()` (строка 245) заменить `self::TABLE_CONFIG` на `AuditChainConfig::TABLES`:

```php
foreach (AuditChainConfig::TABLES as $table => $config) {
    // … остальная логика без изменений
}
```

(d) Метод `buildRowExpression()` (строки 378-386) удалить — заменить вызовы на `AuditChainConfig::rowExpression($table)`. Сигнатура `checkPartition()` изменится: вместо `array $columns` принимает `string $table`, внутри вызывает `AuditChainConfig::rowExpression($table)`.

```php
private function checkPartition(string $partitionName, string $parentTable, string $partition): array
{
    $rowExpr = AuditChainConfig::rowExpression($parentTable);
    // … остальной SQL без изменений (использует $rowExpr)
}
```

В `handle()` вызов меняется:

```php
$breaches = $this->checkPartition($partitionName, $table, $config['partition']);
```

- [ ] **Step 3: Запустить тесты — поведение не должно измениться**

Run: `cd app && ./vendor/bin/pest tests/Feature/Audit/`
Expected: тот же baseline count PASS (та же сумма что в Step 1).

- [ ] **Step 4: Commit**

```bash
git add app/app/Console/Commands/VerifyAuditChains.php
git commit -m "refactor(audit): VerifyAuditChains использует shared AuditChainConfig (ADR-018)"
```

---

### Task 3: Failing tests для per-tenant rebuild

**Files:**
- Modify: `app/tests/Feature/Audit/AuditRebuildChainTest.php` (add 3 scenarios — multi-tenant / BYPASSRLS / single-row)

- [ ] **Step 1: Добавить multi-tenant test (failing)**

В `app/tests/Feature/Audit/AuditRebuildChainTest.php` добавить (после существующего «repairs broken hash chain from given id in activity_log»):

```php
it('audit:rebuild-chain produces per-tenant chain matching trigger semantics в activity_log', function (): void {
    $tenantA = Tenant::factory()->create();
    $tenantB = Tenant::factory()->create();

    // Insert via trigger (per-tenant chain автоматически через RLS).
    DB::statement('SET app.current_tenant_id = '.$tenantA->id);
    DB::table('activity_log')->insert([
        ['tenant_id' => $tenantA->id, 'user_id' => null, 'deal_id' => 1, 'event' => 'deal.a1', 'context' => null, 'created_at' => now()],
        ['tenant_id' => $tenantA->id, 'user_id' => null, 'deal_id' => 1, 'event' => 'deal.a2', 'context' => null, 'created_at' => now()->addMicrosecond()],
    ]);

    DB::statement('SET app.current_tenant_id = '.$tenantB->id);
    DB::table('activity_log')->insert([
        ['tenant_id' => $tenantB->id, 'user_id' => null, 'deal_id' => 2, 'event' => 'deal.b1', 'context' => null, 'created_at' => now()->addMicroseconds(2)],
        ['tenant_id' => $tenantB->id, 'user_id' => null, 'deal_id' => 2, 'event' => 'deal.b2', 'context' => null, 'created_at' => now()->addMicroseconds(3)],
    ]);

    $partition = 'activity_log_y'.now()->format('Y').'_m'.now()->format('m');
    $firstId = (int) DB::connection('pgsql_supplier')->table($partition)->min('id');

    // Sanity: верификатор должен признать целостность сразу после INSERT'а через триггер.
    $preMismatches = checkPartitionIntegrity($partition, 'PARTITION BY tenant_id', ACTIVITY_LOG_ROW_EXPR);
    expect($preMismatches)->toBe(0);

    // Запускаем rebuild с самого начала партиции.
    $exit = Artisan::call('audit:rebuild-chain', [
        '--partition' => $partition,
        '--from-id' => $firstId,
        '--force' => true,
    ]);
    expect($exit)->toBe(0);

    // После rebuild цепочки должны остаться intact per-tenant.
    $postMismatches = checkPartitionIntegrity($partition, 'PARTITION BY tenant_id', ACTIVITY_LOG_ROW_EXPR);
    expect($postMismatches)->toBe(0);
});
```

- [ ] **Step 2: Добавить BYPASSRLS-table test (auth_log global)**

В тот же файл (после multi-tenant test):

```php
const AUTH_LOG_ROW_EXPR = 'ROW(t.id, t.actor_type, t.tenant_id, t.user_id, t.saas_admin_user_id, t.email, t.event, t.ip_address, t.user_agent, t.failure_reason, NULL::bytea, t.created_at)';

it('audit:rebuild-chain produces global chain for BYPASSRLS auth_log', function (): void {
    // auth_log пишется под BYPASSRLS pre-auth role. INSERT direct через pgsql_supplier.
    DB::connection('pgsql_supplier')->table('auth_log')->insert([
        ['actor_type' => 'tenant_user', 'tenant_id' => null, 'event' => 'login', 'email' => 'a@x.com', 'created_at' => now()],
        ['actor_type' => 'tenant_user', 'tenant_id' => null, 'event' => 'login', 'email' => 'b@x.com', 'created_at' => now()->addMicrosecond()],
    ]);

    $partition = 'auth_log_y'.now()->format('Y').'_m'.now()->format('m');
    $firstId = (int) DB::connection('pgsql_supplier')->table($partition)->min('id');

    $preMismatches = checkPartitionIntegrity($partition, '', AUTH_LOG_ROW_EXPR);
    expect($preMismatches)->toBe(0);

    $exit = Artisan::call('audit:rebuild-chain', [
        '--partition' => $partition,
        '--from-id' => $firstId,
        '--force' => true,
    ]);
    expect($exit)->toBe(0);

    $postMismatches = checkPartitionIntegrity($partition, '', AUTH_LOG_ROW_EXPR);
    expect($postMismatches)->toBe(0);
});
```

- [ ] **Step 3: Добавить single-row partition test**

```php
it('audit:rebuild-chain handles single-row partition (first row of tenant) корректно', function (): void {
    $tenant = Tenant::factory()->create();
    DB::statement('SET app.current_tenant_id = '.$tenant->id);

    DB::table('activity_log')->insert([
        'tenant_id' => $tenant->id, 'user_id' => null, 'deal_id' => 1,
        'event' => 'deal.solo', 'context' => null, 'created_at' => now(),
    ]);

    $partition = 'activity_log_y'.now()->format('Y').'_m'.now()->format('m');
    $firstId = (int) DB::connection('pgsql_supplier')->table($partition)->min('id');

    $exit = Artisan::call('audit:rebuild-chain', [
        '--partition' => $partition,
        '--from-id' => $firstId,
        '--force' => true,
    ]);
    expect($exit)->toBe(0);

    $postMismatches = checkPartitionIntegrity($partition, 'PARTITION BY tenant_id', ACTIVITY_LOG_ROW_EXPR);
    expect($postMismatches)->toBe(0);
});
```

- [ ] **Step 4: Run tests — должны fail (rebuild ещё global)**

Run: `cd app && ./vendor/bin/pest tests/Feature/Audit/AuditRebuildChainTest.php`
Expected: existing tests PASS, 3 новых FAIL (multi-tenant создаёт mismatches потому что rebuild делает global вместо per-tenant; single-row и BYPASSRLS могут PASS случайно — но multi-tenant обязательно FAIL).

- [ ] **Step 5: Commit failing tests**

```bash
git add app/tests/Feature/Audit/AuditRebuildChainTest.php
git commit -m "test(audit): failing tests для per-tenant rebuild (ADR-018, RED phase)"
```

---

### Task 4: Реализовать per-tenant rebuild через LAG OVER

**Files:**
- Modify: `app/app/Console/Commands/AuditRebuildChain.php` (целиком переписать `handle()` + удалить `COLUMN_CONFIG` + использовать `AuditChainConfig`)

- [ ] **Step 1: Переписать AuditRebuildChain**

Полная замена `app/app/Console/Commands/AuditRebuildChain.php`:

```php
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Services\Audit\AuditChainConfig;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;

/**
 * Пересчитывает hash-цепь в указанной партиции аудит-таблицы начиная с id.
 *
 * ADR-018: использует тот же partition_clause что VerifyAuditChains для
 * воспроизведения per-tenant scope триггера audit_chain_hash() (через RLS).
 *
 * Алгоритм (pure-SQL):
 *   1. SET session_replication_role = replica (отключаем триггеры).
 *   2. Берём prev_hash для каждой строки с id >= from-id через
 *      LAG(log_hash) OVER ({partition_clause} ORDER BY id) — симметрично verify.
 *   3. UPDATE log_hash для каждой строки в одном SQL.
 *   4. Возвращаем session_replication_role = origin.
 *
 * Параметр partition_clause берётся из AuditChainConfig::TABLES — single
 * source of truth с verify. Для tenant-таблиц = 'PARTITION BY tenant_id',
 * для BYPASSRLS-таблиц (auth_log, saas_admin_audit_log) = '' (global).
 *
 * Ref: docs/adr/ADR-018-audit-chain-per-tenant-semantics.md
 *      docs/superpowers/plans/2026-05-29-audit-rebuild-per-tenant-fix.md
 */
final class AuditRebuildChain extends Command
{
    protected $signature = 'audit:rebuild-chain
        {--partition= : Имя партиции, например activity_log_y2026_m05}
        {--from-id= : ID с которого начать пересчёт (включительно)}
        {--dry-run : Показать сколько строк затронет, без UPDATE}
        {--force : Пропустить интерактивное подтверждение (для CI/тестов)}';

    protected $description = 'Пересчитать hash-цепь партиции аудит-таблицы (per-tenant per ADR-018)';

    public function handle(): int
    {
        $partition = (string) $this->option('partition');
        $fromId = (int) $this->option('from-id');
        $dryRun = (bool) $this->option('dry-run');
        $force = (bool) $this->option('force');

        if ($partition === '' || $fromId <= 0) {
            $this->error('--partition и --from-id обязательны');

            return self::FAILURE;
        }

        $parentTable = (string) preg_replace('/_y\d{4}_m\d{2}$/', '', $partition);

        if (! array_key_exists($parentTable, AuditChainConfig::TABLES)) {
            $this->error("Partition '{$partition}' не относится к зарегистрированным аудит-таблицам.");
            $this->line('Поддерживаемые: '.implode(', ', array_keys(AuditChainConfig::TABLES)));

            return self::FAILURE;
        }

        $partitionClause = AuditChainConfig::TABLES[$parentTable]['partition'];
        $rowExpr = AuditChainConfig::rowExpression($parentTable);

        $count = DB::connection('pgsql_supplier')
            ->table($partition)
            ->where('id', '>=', $fromId)
            ->count();

        $this->info("Партиция : {$partition}");
        $this->info("Родитель : {$parentTable}");
        $this->info("Scope    : ".($partitionClause !== '' ? $partitionClause : 'global (within partition)'));
        $this->info("От id    : {$fromId}");
        $this->info("Строк    : {$count}");

        if ($count === 0) {
            $this->warn('Нет строк с id >= '.$fromId.'. Пересчёт не нужен.');

            return self::SUCCESS;
        }

        if ($dryRun) {
            $this->warn('--dry-run: UPDATE не выполнен.');

            return self::SUCCESS;
        }

        if (! $force && ! $this->confirm(
            "Пересчитать log_hash для {$count} строк в {$partition} (scope: ".
            ($partitionClause !== '' ? $partitionClause : 'global').")? Это изменит данные в проде.",
            false,
        )) {
            $this->warn('Отменено.');

            return self::FAILURE;
        }

        // Disable BEFORE triggers (audit_block_mutation blocks UPDATE).
        // Session-level SET переживает оборачивающую транзакцию (DatabaseTransactions в тестах).
        DB::connection('pgsql_supplier')->statement("SET session_replication_role = 'replica'");

        try {
            $overClause = $partitionClause !== ''
                ? "({$partitionClause} ORDER BY id)"
                : '(ORDER BY id)';

            // Single SQL: LAG даёт prev_hash на каждую строку в её partition-scope.
            // Симметрично VerifyAuditChains::checkPartition().
            $sql = <<<SQL
                WITH ordered AS (
                    SELECT
                        id,
                        LAG(log_hash) OVER {$overClause} AS prev_hash
                    FROM {$partition}
                ),
                recomputed AS (
                    SELECT
                        o.id,
                        digest(
                            COALESCE(o.prev_hash, ''::bytea)
                            || (SELECT {$rowExpr}::text::bytea FROM {$partition} t WHERE t.id = o.id),
                            'sha256'
                        ) AS new_hash
                    FROM ordered o
                    WHERE o.id >= ?
                )
                UPDATE {$partition} p
                SET log_hash = r.new_hash
                FROM recomputed r
                WHERE p.id = r.id
            SQL;

            $updated = DB::connection('pgsql_supplier')->update($sql, [$fromId]);
            $this->info("Обновлено {$updated} строк в {$partition}.");
        } finally {
            DB::connection('pgsql_supplier')->statement("SET session_replication_role = 'origin'");
        }

        $this->info('Готово. Запустите audit:verify-chains для проверки целостности.');

        return self::SUCCESS;
    }
}
```

- [ ] **Step 2: Run new + existing tests — должны PASS**

Run: `cd app && ./vendor/bin/pest tests/Feature/Audit/AuditRebuildChainTest.php`
Expected: ALL tests PASS (existing + 3 новых).

- [ ] **Step 3: Run full audit tests regression**

Run: `cd app && ./vendor/bin/pest tests/Feature/Audit/`
Expected: тот же baseline что в Task 2 Step 1 (плюс +3 новых тестов из Task 3) — все PASS.

- [ ] **Step 4: Commit GREEN**

```bash
git add app/app/Console/Commands/AuditRebuildChain.php
git commit -m "fix(audit): AuditRebuildChain per-tenant LAG OVER (ADR-018, closes Stage 5 #1)"
```

---

### Task 5: Активировать ADR-018 Enforcement rule

**Files:**
- Modify: `docs/adr/ADR-018-audit-chain-per-tenant-semantics.md` (Enforcement-блок — снять «активируется после имплементации» note + проверить что rule срабатывает)

- [ ] **Step 1: Обновить Enforcement-блок**

Заменить `## Enforcement` секцию в `docs/adr/ADR-018-audit-chain-per-tenant-semantics.md`:

````markdown
## 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 этого плана.
````

- [ ] **Step 2: Запустить adr-judge на staged ADR**

Run: `git add docs/adr/ADR-018-audit-chain-per-tenant-semantics.md && python tools/adr-judge.py --staged-only`
Expected: 0 violations, 0 advisory.

- [ ] **Step 3: Commit Enforcement update**

```bash
git commit -m "docs(adr): ADR-018 enforcement активирован (Tasks 2+4 завершены)"
```

---

### Task 6: Local smoke + Larastan/Pint

**Files:** (no file changes — verification только)

- [ ] **Step 1: Pint code style**

Run: `cd app && ./vendor/bin/pint app/Services/Audit/AuditChainConfig.php app/Console/Commands/AuditRebuildChain.php app/Console/Commands/VerifyAuditChains.php tests/Unit/Services/Audit/AuditChainConfigTest.php tests/Feature/Audit/AuditRebuildChainTest.php`
Expected: «X files would be modified» = 0 (или auto-fix применён без ошибок).

- [ ] **Step 2: Larastan**

Run: `cd app && ./vendor/bin/phpstan analyse app/Services/Audit/AuditChainConfig.php app/Console/Commands/AuditRebuildChain.php app/Console/Commands/VerifyAuditChains.php --level=max`
Expected: «[OK] No errors».

- [ ] **Step 3: Full Pest parallel regression**

Run: `cd app && ./vendor/bin/pest --parallel --recreate-databases`
Expected: тот же baseline что был до плана плюс +6 новых тестов (5 в AuditChainConfigTest + 3 в AuditRebuildChainTest, существующие модифицированы не были). 0 failures.

NB: возможны pre-existing quirks 72/73/77 (Redis race / cumulative state / Faker collision) — если они появятся, классифицировать через `pest-parallel-debugger` агент, **не** считать regression этого плана.

- [ ] **Step 4: Commit (only if Pint auto-fixed что-то)**

```bash
git add -u app/
git commit -m "style(audit): pint auto-fix на shared config + rebuild rewrite"
```

(Если Pint ничего не правил — skip Step 4.)

---

### Task 7: Handoff для прод-выкатки cleanup'а activity_log_y2026_m05

**Files:**
- Create: `docs/incidents/2026-05-29-audit-rebuild-per-tenant-cleanup-handoff.md`

- [ ] **Step 1: Создать handoff-док**

Create `docs/incidents/2026-05-29-audit-rebuild-per-tenant-cleanup-handoff.md`:

````markdown
# Handoff: cleanup `activity_log_y2026_m05` после ADR-018 fix

**Что:** удалить 6 mismatches в `activity_log_y2026_m05` через re-run исправленного `audit:rebuild-chain` per ADR-018.

**Когда:** после merge всех task-коммитов этого плана в `origin/main` и успешного deploy через `gh workflow run deploy.yml`.

**Кто:** controller / Дмитрий (mutating prod operation — требует confirm_apply=true).

## Pre-flight checks

1. **Deploy завершён успешно** — `gh run list --workflow=deploy.yml --limit 1` показывает `success`.
2. **Master verify падает только на 6 строках activity_log_y2026_m05** (baseline до cleanup'а):

   ```bash
   gh workflow run artisan-run.yml -f command=$(printf 'audit:verify-chains' | base64 -w0)
   ```

   Ждать `success` workflow → читать output. Expected: `activity_log_y2026_m05: 6 mismatch(es), first broken id=NNN`, остальные партиции `intact`.

## Dry-run

3. **Запустить rebuild --dry-run** на проде (через artisan-run workflow whitelist):

   ```bash
   gh workflow run artisan-run.yml -f command=$(printf 'audit:rebuild-chain --partition=activity_log_y2026_m05 --from-id=NNN --dry-run' | base64 -w0)
   ```

   где `NNN` — `first broken id` из шага 2.
   Expected output: `Партиция : activity_log_y2026_m05` / `Scope : PARTITION BY tenant_id` / `От id : NNN` / `Строк : M` / `--dry-run: UPDATE не выполнен.` Прикинуть M на разумность (сотни-тысячи, не миллионы).

## Apply (mutating)

4. **Запустить rebuild с force + confirm_apply**:

   ```bash
   gh workflow run artisan-run.yml \
     -f command=$(printf 'audit:rebuild-chain --partition=activity_log_y2026_m05 --from-id=NNN --force' | base64 -w0) \
     -f confirm_apply=true
   ```

   Expected output: `Обновлено M строк в activity_log_y2026_m05.`

## Verify

5. **Запустить verify ещё раз** (тот же шаг 2 базовая команда):

   ```bash
   gh workflow run artisan-run.yml -f command=$(printf 'audit:verify-chains' | base64 -w0)
   ```

   Expected: `activity_log_y2026_m05: chain intact`. Все 6 audit-таблиц `intact`.
   Если ещё mismatches — НЕ продолжать, открыть отдельный incident (signal что rebuild не покрыл какой-то edge case).

## Post-cleanup

6. **Закрыть incident-запись** в `incidents_log` через SaaS-admin UI (Системные инциденты): resolved_at = now(), root_cause = «cleanup per ADR-018 rebuild fix».

7. **Обновить memory** `feedback_audit_chain_algorithm_divergence.md` — статус «6 mismatches исчезли DD.MM.2026, ADR-018 implementation Stage 5 follow-up закрыт».

## Rollback

Если шаг 4 повёл себя неожиданно (например, обновлено существенно больше строк чем dry-run):

- **НЕ паниковать** — записи защищены `audit_block_mutation` триггером (UPDATE/DELETE невозможен извне rebuild'а).
- Восстановить из бэкапа PG (последний автоматический + `audit_chain_hash`-snapshot перед запуском).
- Open incident, классифицировать root cause.
````

- [ ] **Step 2: Commit handoff**

```bash
git add docs/incidents/2026-05-29-audit-rebuild-per-tenant-cleanup-handoff.md
git commit -m "docs(incidents): handoff для cleanup activity_log_y2026_m05 после ADR-018 fix"
```

---

### Task 8: Финальный push + closure-сообщение

- [ ] **Step 1: Sync с remote, push всех task-коммитов**

```bash
git fetch origin main
git log HEAD..origin/main --oneline
```

Если HEAD..origin/main пуст — fast-forward push. Если что-то прилетело — rebase pattern (`git stash push docs/observer/`, rebase, drop stash; см. memory `feedback_rebase_observer_dirt.md`).

```bash
git push origin main
```

Expected: lefthook pre-push (gitleaks-full-history + lychee) GREEN, push OK.

- [ ] **Step 2: Сообщить Дмитрию готовность к выкатке**

Сообщение пользователю:

> «ADR-018 Stage 5 follow-up implementation готов. Push на `origin/main` коммитами TaskN..TaskN+M. Регрессия: Pest +6 тестов GREEN, Larastan / Pint OK, adr-judge enforcement активирован. Что осталось — выкатка через `gh workflow run deploy.yml --ref main` + cleanup на проде по handoff'у `docs/incidents/2026-05-29-audit-rebuild-per-tenant-cleanup-handoff.md`. Запускать?»

---

## Self-Review

**1. Spec coverage (ADR-018):**

- ✅ Decision item 1 (Writer без изменений) — нигде не модифицирован.
- ✅ Decision item 2 (Verify без поведенческих изменений) — Task 2 refactor regression-safe (тот же baseline тестов).
- ✅ Decision item 3 (Rebuild переделан под per-partition_clause) — Task 4.
- ✅ Decision item 4 (cleanup `activity_log_y2026_m05`) — Task 7 handoff.
- ✅ Risk «Shared config single source» — Task 1 (AuditChainConfig) + Task 5 (Enforcement rules на оба consumer'а).
- ✅ Risk «edge cases pure-tenant / mixed / single-row / BYPASSRLS» — Task 3 (3 новых теста: multi-tenant / BYPASSRLS / single-row; pure-tenant покрыт existing test'ом).

**2. Placeholder scan:** none — все steps содержат конкретные команды и/или код, нет «TBD»/«similar to»/«add appropriate».

**3. Type consistency:**

- `AuditChainConfig::TABLES` структура `array{columns: list<string>, partition: string}` — одинаково в Task 1 (definition) / Task 2 (VerifyAuditChains consumer) / Task 4 (AuditRebuildChain consumer).
- `AuditChainConfig::rowExpression(string $table): string` — одинаково в Task 1 (definition) / Tasks 2+4 (consumers).
- `checkPartitionIntegrity()` helper — существующий из AuditRebuildChainTest, переиспользуется без изменений в Task 3.
- ROW expressions inline-константы (`ACTIVITY_LOG_ROW_EXPR` / `AUTH_LOG_ROW_EXPR` / `BALANCE_TX_ROW_EXPR` в тестах) — соответствуют `AuditChainConfig::rowExpression()` output (один и тот же string).

---

## Execution Handoff

Plan complete and saved to `docs/superpowers/plans/2026-05-29-audit-rebuild-per-tenant-fix.md`. Two execution options:

**1. Subagent-Driven (recommended)** — fresh subagent на task, review между tasks, быстрая итерация. Sonnet only per Pravila §15.1. NB: per memory `feedback_subagent_falsified_test_results.md` — controller обязан независимо verify Pest output на каждом task'е (требовать verbatim в prompt).

**2. Inline Execution** — все Tasks в текущей сессии через `superpowers:executing-plans`, batch с checkpoints.

**Which approach?**
