From e964d70c28725e4e9b282af3e6984771e68be4bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 29 May 2026 15:56:35 +0300 Subject: [PATCH] =?UTF-8?q?docs(plans):=20ADR-018=20Stage=205=20follow-up?= =?UTF-8?q?=20=E2=80=94=20AuditRebuildChain=20per-tenant=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 TDD tasks (~день кода): extract shared AuditChainConfig, refactor VerifyAuditChains (regression-safe), failing tests для multi-tenant/BYPASSRLS/single-row, rewrite AuditRebuildChain через LAG OVER (partition_clause ORDER BY id) симметрично verify, активация ADR-018 enforcement rules, Pint/Larastan/Pest --parallel smoke, handoff для прод-cleanup activity_log_y2026_m05 через gh workflow run artisan-run.yml. Self-review GREEN на spec coverage / placeholders / типы. Execution mode: subagent-driven. --- ...2026-05-29-audit-rebuild-per-tenant-fix.md | 789 ++++++++++++++++++ 1 file changed, 789 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-29-audit-rebuild-per-tenant-fix.md diff --git a/docs/superpowers/plans/2026-05-29-audit-rebuild-per-tenant-fix.md b/docs/superpowers/plans/2026-05-29-audit-rebuild-per-tenant-fix.md new file mode 100644 index 00000000..3c7a0e97 --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-audit-rebuild-per-tenant-fix.md @@ -0,0 +1,789 @@ +# Audit Rebuild Per-Tenant Fix 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:** Переписать `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 +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 +, 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 += 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 = <<= ? + ) + 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, 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?**