docs(plans): ADR-018 Stage 5 follow-up — AuditRebuildChain per-tenant fix

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.
This commit is contained in:
Дмитрий
2026-05-29 15:56:35 +03:00
parent 0098db6628
commit e964d70c28
@@ -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
<?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?**