# P0 — Журнал ПДн + Impersonation аудит (152-ФЗ closure)

> **Status: ✅ DONE — 22.05.2026.** Subagent-driven execution на ветке `worktree-audit-p0-pd` (от `9bf97ef`). 13 commits (Tasks 1-12 + gate b9e4e03). Pd-suite **22/22 passing (100 assertions)**; touched-area regression **149/150** — единственное падение `RouteSupplierLeadJobTest::it_is_terminal` подтверждено как pre-existing (фейлит и на parent `f6d83f6` до правок Task 3; корень — deal-leak через `pgsql_supplier` BYPASSRLS connection вне `DatabaseTransactions`, не связан с PD-эпиком). Pint **clean** на изменённых файлах; Larastan **production code clean** (один реальный `nullsafe.neverNull` в `ImpersonationAuditService` исправлен; остаточные 120 ошибок — Pest false-positives в новых тестовых файлах + ide-helper baseline drift, известный worktree-квирк per memory). Plan-level deviations: (1) Tasks 9-11 объединены в один commit per plan hint «commit single»; (2) Task 5 implementer изначально написал `subject_type='report_file'` / `purpose='report_file_delete'` — поправлено fixup'ом до spec'овых `subject_type='lead'` / `purpose='report_file_'.$job->id` (consistency с Task 6 + Task 12 integration test); (3) Task 7 потребовал thread `$importLogId` через `upsertRow()` — план предполагал `$log->id` в scope, но он там не был. NB: `--parallel` full-suite не запущен (shared `liderra_testing` DB + 13 active worktrees → noise), вместо него targeted sequential regression — гейт-equivalent для аддитивного эпика.

> **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:** Закрыть журнал `pd_processing_log` во всех точках обработки ПДн (created/viewed/exported/deleted) и защищённый аудит impersonation (`saas_admin_audit_log` + ПДн-след) — соответствие 152-ФЗ ст.18 ч.2.

**Architecture:**

1. Сервис `App\Services\Pd\PdAuditLogger` — единственная точка записи в `pd_processing_log`. Через DI внедряется в контроллеры/джобы/команды; явные вызовы в местах операций.
2. Hash-chain и append-only защита стоит триггерами схемы ([db/schema.sql:3046-3051](../../../db/schema.sql#L3046)) — сервис только формирует строку, БД гарантирует целостность.
3. Impersonation использует `App\Services\Pd\ImpersonationAuditService` — пишет `saas_admin_audit_log` на init/verify/end и `pd_processing_log` один раз на сессию (гибрид C=c из решений: session-level + per-export если экспорт идёт изнутри impersonation).
4. Backfill прошлых строк НЕ выполняется (решение B=a) — только новые записи.

**Tech Stack:** PHP 8.3, Laravel 13, Pest 4 (parallel), PostgreSQL 16, существующий триггер `audit_chain_hash()` (`db/schema.sql:2992`).

**Источник дыр:** [реальный аудит](#) этой сессии — `pd_processing_log` на dev и тест-сервере = 0 строк, при том что код экспорта/просмотра/удаления телефонов выполнялся многократно.

---

## File Structure

**New (10 файлов):**

- `app/app/Services/Pd/PdAuditLogger.php` — запись в `pd_processing_log`.
- `app/app/Services/Pd/ImpersonationAuditService.php` — оркестратор impersonation-событий в оба журнала.
- `app/tests/Unit/Services/Pd/PdAuditLoggerTest.php`
- `app/tests/Unit/Services/Pd/ImpersonationAuditServiceTest.php`
- `app/tests/Feature/Pd/DealViewAccessLogTest.php`
- `app/tests/Feature/Pd/DealCreatePdLogTest.php`
- `app/tests/Feature/Pd/DealExportPdLogTest.php`
- `app/tests/Feature/Pd/ReportFileDeletePdLogTest.php`
- `app/tests/Feature/Pd/ImpersonationAuditTest.php`
- `app/tests/Feature/Pd/PdFullFlowIntegrationTest.php`

**Modified:**

- `app/app/Http/Controllers/Api/DealController.php` — `show()` + `store()`.
- `app/app/Http/Controllers/Api/DealExportController.php` — `export()`.
- `app/app/Http/Controllers/Api/ReportJobController.php` — `destroy()`.
- `app/app/Console/Commands/ReportsCleanupExpired.php` — `handle()` per-file.
- `app/app/Http/Controllers/Api/ImpersonationController.php` — init/verify/end.
- `app/app/Jobs/ProcessWebhookJob.php` — после ActivityLog::create на deal.
- `app/app/Jobs/RouteSupplierLeadJob.php` — после ActivityLog::create на deal.
- `app/app/Services/Import/HistoricalImportService.php` — per-imported лид.

---

## Task 1 — `PdAuditLogger` service

**Files:**

- Create: `app/app/Services/Pd/PdAuditLogger.php`
- Test: `app/tests/Unit/Services/Pd/PdAuditLoggerTest.php`

- [ ] **Step 1: failing test**

```php
<?php declare(strict_types=1);

use App\Services\Pd\PdAuditLogger;
use Illuminate\Support\Facades\DB;

it('inserts pd_processing_log row with all fields', function () {
    app(PdAuditLogger::class)->record(
        action: 'viewed', subjectType: 'lead', subjectId: 123,
        purpose: 'lead_card_view', tenantId: 1,
        actorTenantUserId: 7, actorAdminUserId: null, ip: '10.0.0.1',
    );

    $row = DB::table('pd_processing_log')->latest('id')->first();
    expect($row->action)->toBe('viewed')
        ->and($row->subject_type)->toBe('lead')
        ->and((int) $row->subject_id)->toBe(123)
        ->and((int) $row->actor_tenant_user_id)->toBe(7)
        ->and((string) $row->ip_address)->toBe('10.0.0.1');
});

it('allows system actor (both NULL) per chk_pd_actor', function () {
    app(PdAuditLogger::class)->record(
        action: 'exported', subjectType: 'lead', subjectId: null,
        purpose: 'cron_cleanup', tenantId: 1,
        actorTenantUserId: null, actorAdminUserId: null, ip: null,
    );
    expect(DB::table('pd_processing_log')->count())->toBe(1);
});

it('rejects two-actor row (chk_pd_actor violation)', function () {
    expect(fn () => app(PdAuditLogger::class)->record(
        action: 'viewed', subjectType: 'lead', subjectId: 1,
        purpose: 'x', tenantId: 1,
        actorTenantUserId: 7, actorAdminUserId: 99, ip: null,
    ))->toThrow(\Illuminate\Database\QueryException::class);
});
```

- [ ] **Step 2: confirm RED**

```bash
cd app && php artisan test --filter=PdAuditLoggerTest
```

Expected: FAIL (`Class "App\Services\Pd\PdAuditLogger" not found`).

- [ ] **Step 3: implement**

```php
<?php declare(strict_types=1);

namespace App\Services\Pd;

use Illuminate\Support\Facades\DB;

/**
 * Запись в pd_processing_log (152-ФЗ ст.18 ч.2). Hash-chain trigger
 * audit_chain_hash() (db/schema.sql:3046) автоматически заполняет log_hash.
 * Append-only: UPDATE/DELETE заблокированы audit_block_mutation.
 *
 * chk_pd_actor (db/schema.sql:2461): ровно один актор из tenant_user/admin,
 * либо оба NULL (системное действие — cron / триггер).
 */
final class PdAuditLogger
{
    /** @param string $action one of 'created','viewed','updated','deleted','exported' */
    public function record(
        string $action,
        ?string $subjectType,
        ?int $subjectId,
        string $purpose,
        ?int $tenantId,
        ?int $actorTenantUserId,
        ?int $actorAdminUserId,
        ?string $ip,
    ): void {
        DB::table('pd_processing_log')->insert([
            'tenant_id' => $tenantId,
            'subject_type' => $subjectType,
            'subject_id' => $subjectId,
            'action' => $action,
            'purpose' => $purpose,
            'actor_tenant_user_id' => $actorTenantUserId,
            'actor_admin_user_id' => $actorAdminUserId,
            'ip_address' => $ip,
            'created_at' => now(),
        ]);
    }
}
```

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

```bash
cd app && php artisan test --filter=PdAuditLoggerTest
```

Expected: 3/3 PASS.

- [ ] **Step 5: commit**

```bash
git add app/app/Services/Pd/PdAuditLogger.php app/tests/Unit/Services/Pd/PdAuditLoggerTest.php
git commit -m "feat(pd): PdAuditLogger service (152-ФЗ pd_processing_log writer)"
```

---

## Task 2 — DealController.show → pd 'viewed'

**Files:**

- Modify: `app/app/Http/Controllers/Api/DealController.php:244-315`
- Test: `app/tests/Feature/Pd/DealViewAccessLogTest.php` (NEW)

- [ ] **Step 1: failing test**

```php
<?php declare(strict_types=1);

use App\Models\Deal;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\DB;

beforeEach(function () {
    $this->tenant = Tenant::factory()->create();
    $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
    $this->deal = Deal::factory()->create(['tenant_id' => $this->tenant->id]);
});

it('writes pd_processing_log viewed when deal card opened', function () {
    $this->actingAs($this->user)
        ->getJson("/api/deals/{$this->deal->id}")
        ->assertOk();

    $row = DB::table('pd_processing_log')->latest('id')->first();
    expect($row)->not->toBeNull()
        ->and($row->action)->toBe('viewed')
        ->and($row->subject_type)->toBe('lead')
        ->and((int) $row->subject_id)->toBe($this->deal->id)
        ->and((int) $row->actor_tenant_user_id)->toBe($this->user->id);
});

it('does not write pd_processing_log for 404 lookups', function () {
    $this->actingAs($this->user)
        ->getJson('/api/deals/999999')->assertNotFound();
    expect(DB::table('pd_processing_log')->count())->toBe(0);
});
```

- [ ] **Step 2: confirm RED**

```bash
cd app && php artisan test --filter=DealViewAccessLogTest
```

Expected: FAIL.

- [ ] **Step 3: implement — inject logger + добавить вызов в `DealController::show()` после `if ($deal === null) return 404`**

```php
// app/app/Http/Controllers/Api/DealController.php — add to use list
use App\Services\Pd\PdAuditLogger;

// сигнатура show() расширяется (Laravel auto-resolves через container):
public function show(Request $request, int $id, PdAuditLogger $pdLog): JsonResponse
{
    $tenantId = (int) $request->user()->tenant_id;
    // ... existing transaction logic ...
    if ($deal === null) {
        return response()->json(['message' => 'Сделка не найдена.'], 404);
    }

    $pdLog->record(
        action: 'viewed', subjectType: 'lead', subjectId: $deal->id,
        purpose: 'lead_card_view', tenantId: $tenantId,
        actorTenantUserId: (int) $request->user()->id,
        actorAdminUserId: null, ip: $request->ip(),
    );

    return response()->json([...]); // existing payload unchanged
}
```

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

```bash
cd app && php artisan test --filter=DealViewAccessLogTest
```

Expected: 2/2 PASS.

- [ ] **Step 5: commit**

```bash
git add app/app/Http/Controllers/Api/DealController.php app/tests/Feature/Pd/DealViewAccessLogTest.php
git commit -m "feat(pd): pd_processing_log 'viewed' on deal card open (152-ФЗ)"
```

---

## Task 3 — Deal-creation paths → pd 'created' (3 точки)

**Files:**

- Modify: `app/app/Http/Controllers/Api/DealController.php:523` (manual store)
- Modify: `app/app/Jobs/ProcessWebhookJob.php:147`, `:232` (webhook + duplicate)
- Modify: `app/app/Jobs/RouteSupplierLeadJob.php:285`, `:308` (supplier route + duplicate)
- Test: `app/tests/Feature/Pd/DealCreatePdLogTest.php` (NEW)

- [ ] **Step 1: failing test (три сценария)**

```php
<?php declare(strict_types=1);

use App\Jobs\ProcessWebhookJob;
use App\Jobs\RouteSupplierLeadJob;
use App\Models\Deal;
use App\Models\SupplierLead;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\DB;

it('pd created on manual store via /api/deals', function () {
    $tenant = Tenant::factory()->create();
    $user = User::factory()->create(['tenant_id' => $tenant->id]);

    $this->actingAs($user)->postJson('/api/deals', [
        'project_name' => 'Test', 'phone' => '79991234567',
    ])->assertCreated();

    $pd = DB::table('pd_processing_log')->where('action', 'created')->latest('id')->first();
    expect($pd)->not->toBeNull()
        ->and($pd->purpose)->toBe('lead_create_manual')
        ->and((int) $pd->actor_tenant_user_id)->toBe($user->id);
});

it('pd created on supplier webhook path', function () {
    // ... setup tenant, project, supplier_lead ...
    // dispatch RouteSupplierLeadJob synchronously, then assert pd row exists
    // with purpose='lead_create_supplier' и subject_id равен новому deal_id
});

it('pd created on per-tenant webhook path (ProcessWebhookJob)', function () {
    // ... similar для ProcessWebhookJob (purpose='lead_create_webhook')
});
```

- [ ] **Step 2: confirm RED**

- [ ] **Step 3: implement — три точки**

```php
// DealController.php:523 — после ActivityLog::create:
$pdLog->record(
    action: 'created', subjectType: 'lead', subjectId: $deal->id,
    purpose: 'lead_create_manual', tenantId: $tenantId,
    actorTenantUserId: (int) $request->user()->id,
    actorAdminUserId: null, ip: request()->ip(),
);

// ProcessWebhookJob.php:147 и :232 — после каждой ActivityLog::create:
app(PdAuditLogger::class)->record(
    action: 'created', subjectType: 'lead', subjectId: $deal->id,
    purpose: 'lead_create_webhook', tenantId: $deal->tenant_id,
    actorTenantUserId: null, actorAdminUserId: null, ip: null,
);

// RouteSupplierLeadJob.php:285 и :308 — аналогично, purpose='lead_create_supplier'.
```

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

```bash
cd app && php artisan test --filter=DealCreatePdLogTest
```

Expected: 3/3 PASS.

- [ ] **Step 5: commit**

```bash
git add app/app/Http/Controllers/Api/DealController.php app/app/Jobs/ProcessWebhookJob.php app/app/Jobs/RouteSupplierLeadJob.php app/tests/Feature/Pd/DealCreatePdLogTest.php
git commit -m "feat(pd): pd_processing_log 'created' on deal creation (manual/webhook/supplier)"
```

---

## Task 4 — DealExportController → pd 'exported'

**Files:**

- Modify: `app/app/Http/Controllers/Api/DealExportController.php:43-127`
- Test: `app/tests/Feature/Pd/DealExportPdLogTest.php` (NEW)

- [ ] **Step 1: failing test**

```php
<?php declare(strict_types=1);

use App\Models\Tenant;
use App\Models\User;
use App\Models\Deal;
use Illuminate\Support\Facades\DB;

it('pd exported on deals CSV export', function () {
    $tenant = Tenant::factory()->create();
    $user = User::factory()->create(['tenant_id' => $tenant->id]);
    Deal::factory()->count(3)->create(['tenant_id' => $tenant->id]);

    $this->actingAs($user)->postJson('/api/deals/export', ['format' => 'csv'])->assertOk();

    $pd = DB::table('pd_processing_log')->where('action', 'exported')->latest('id')->first();
    expect($pd)->not->toBeNull()
        ->and($pd->subject_type)->toBe('lead')
        ->and($pd->subject_id)->toBeNull() // bulk
        ->and($pd->purpose)->toBe('deals_export_csv')
        ->and((int) $pd->actor_tenant_user_id)->toBe($user->id);
});

it('pd exported with xlsx purpose', function () {
    // аналогично, purpose='deals_export_xlsx'
});
```

- [ ] **Step 2: confirm RED**
- [ ] **Step 3: implement — добавить вызов до StreamedResponse**

```php
// app/app/Http/Controllers/Api/DealExportController.php:51 — после $tenantId/$format резолва:
app(\App\Services\Pd\PdAuditLogger::class)->record(
    action: 'exported', subjectType: 'lead', subjectId: null,
    purpose: 'deals_export_'.$format,
    tenantId: $tenantId,
    actorTenantUserId: (int) $request->user()->id,
    actorAdminUserId: null, ip: $request->ip(),
);
```

- [ ] **Step 4: confirm GREEN**
- [ ] **Step 5: commit**

```bash
git add app/app/Http/Controllers/Api/DealExportController.php app/tests/Feature/Pd/DealExportPdLogTest.php
git commit -m "feat(pd): pd_processing_log 'exported' on deals export (152-ФЗ)"
```

---

## Task 5 — ReportJobController.destroy → pd 'deleted'

**Files:**

- Modify: `app/app/Http/Controllers/Api/ReportJobController.php:308-343`
- Test: `app/tests/Feature/Pd/ReportFileDeletePdLogTest.php` (NEW)

- [ ] **Step 1: failing test**

```php
it('pd deleted on report file destroy', function () {
    // setup report_job done + file_path != null, then DELETE /api/reports/jobs/{id}
    // assert pd_processing_log has action='deleted', purpose='report_file_'.$id
});
```

- [ ] **Step 2: confirm RED**
- [ ] **Step 3: implement — вставить в `destroy()` после `if ($job->file_path !== null) Storage::disk('local')->delete(...)`**

```php
app(\App\Services\Pd\PdAuditLogger::class)->record(
    action: 'deleted', subjectType: 'lead', subjectId: null,
    purpose: 'report_file_'.$job->id, tenantId: $job->tenant_id,
    actorTenantUserId: (int) $request->user()->id,
    actorAdminUserId: null, ip: $request->ip(),
);
```

- [ ] **Step 4: confirm GREEN**
- [ ] **Step 5: commit**

---

## Task 6 — ReportsCleanupExpired (cron) → pd 'deleted' (per file)

**Files:**

- Modify: `app/app/Console/Commands/ReportsCleanupExpired.php:60-75`
- Test: `app/tests/Feature/Pd/ReportFileDeletePdLogTest.php` (расширить)

- [ ] **Step 1: failing test** — добавить кейс «cron удаляет N expired → ровно N строк `action=deleted, actor оба NULL, purpose='report_cleanup_expired_'.$id`».

- [ ] **Step 2: confirm RED**
- [ ] **Step 3: implement — в цикле перед `$job->update(['file_path' => null])`**

```php
if (! $dryRun) {
    Storage::disk('local')->delete($job->file_path);
    app(\App\Services\Pd\PdAuditLogger::class)->record(
        action: 'deleted', subjectType: 'lead', subjectId: null,
        purpose: 'report_cleanup_expired_'.$job->id, tenantId: $job->tenant_id,
        actorTenantUserId: null, actorAdminUserId: null, ip: null,
    );
    $job->update(['file_path' => null]);
}
```

- [ ] **Step 4: confirm GREEN**
- [ ] **Step 5: commit**

---

## Task 7 — HistoricalImportService → pd 'created' (per row)

**Files:**

- Modify: `app/app/Services/Import/HistoricalImportService.php:250-270`
- Test: `app/tests/Feature/Pd/DealCreatePdLogTest.php` (расширить — кейс «импорт N лидов → N pd-строк action=created, purpose='lead_create_import_'.$importLogId»).

- [ ] **Step 1: failing test**
- [ ] **Step 2: confirm RED**
- [ ] **Step 3: implement — в цикле upsert лидов после успешной вставки строки в deals**

```php
// внутри HistoricalImportService::import() — в callback после INSERT в deals:
$this->pdLog->record(
    action: 'created', subjectType: 'lead', subjectId: $dealId,
    purpose: 'lead_create_import_'.$log->id, tenantId: $tenantId,
    actorTenantUserId: $userId, actorAdminUserId: null, ip: null,
);
```

(внедрить `PdAuditLogger` в конструктор сервиса).

- [ ] **Step 4: confirm GREEN**
- [ ] **Step 5: commit**

---

## Task 8 — `ImpersonationAuditService` (unit-tested)

**Files:**

- Create: `app/app/Services/Pd/ImpersonationAuditService.php`
- Test: `app/tests/Unit/Services/Pd/ImpersonationAuditServiceTest.php`

- [ ] **Step 1: failing test**

```php
<?php declare(strict_types=1);

use App\Models\ImpersonationToken;
use App\Services\Pd\ImpersonationAuditService;
use Illuminate\Support\Facades\DB;

it('recordInit writes saas_admin_audit_log action=impersonation.init', function () {
    $token = ImpersonationToken::factory()->create(['reason' => 'Lorem '.str_repeat('x', 30)]);
    app(ImpersonationAuditService::class)->recordInit($token, adminId: 1, ip: '1.2.3.4');
    $row = DB::table('saas_admin_audit_log')->latest('id')->first();
    expect($row->action)->toBe('impersonation.init')
        ->and((int) $row->target_id)->toBe($token->tenant_id)
        ->and($row->reason)->toBe($token->reason);
});

it('recordVerify writes BOTH saas_audit and pd_processing_log', function () {
    $token = ImpersonationToken::factory()->create();
    app(ImpersonationAuditService::class)->recordVerify($token, adminId: 1, ip: '1.2.3.4');
    expect(DB::table('saas_admin_audit_log')->where('action', 'impersonation.verify')->count())->toBe(1)
        ->and(DB::table('pd_processing_log')
            ->where('action', 'viewed')
            ->where('purpose', 'impersonation_session_'.$token->id)
            ->count())->toBe(1);
});

it('recordEnd writes saas_admin_audit_log action=impersonation.end', function () {
    // ...
});
```

- [ ] **Step 2: confirm RED**
- [ ] **Step 3: implement**

```php
<?php declare(strict_types=1);

namespace App\Services\Pd;

use App\Models\ImpersonationToken;
use App\Models\SaasAdminAuditLog;

final class ImpersonationAuditService
{
    public function __construct(private readonly PdAuditLogger $pd) {}

    public function recordInit(ImpersonationToken $t, int $adminId, ?string $ip): void
    {
        SaasAdminAuditLog::create([
            'admin_user_id' => $adminId, 'action' => 'impersonation.init',
            'target_type' => 'tenant', 'target_id' => $t->tenant_id,
            'target_tenant_id' => $t->tenant_id,
            'payload_before' => null,
            'payload_after' => ['token_id' => $t->id, 'expires_at' => $t->expires_at?->toIso8601String()],
            'reason' => $t->reason, 'ip_address' => $ip ?? '127.0.0.1',
            'user_agent' => null,
        ]);
    }

    public function recordVerify(ImpersonationToken $t, int $adminId, ?string $ip): void
    {
        SaasAdminAuditLog::create([
            'admin_user_id' => $adminId, 'action' => 'impersonation.verify',
            'target_type' => 'tenant', 'target_id' => $t->tenant_id,
            'target_tenant_id' => $t->tenant_id,
            'payload_before' => ['used_at' => null],
            'payload_after' => ['used_at' => now()->toIso8601String()],
            'reason' => $t->reason, 'ip_address' => $ip ?? '127.0.0.1',
            'user_agent' => null,
        ]);
        // PD-след: вход админа в кабинет = массовый доступ к ПДн tenant'а.
        $this->pd->record(
            action: 'viewed', subjectType: 'tenant', subjectId: $t->tenant_id,
            purpose: 'impersonation_session_'.$t->id,
            tenantId: $t->tenant_id,
            actorTenantUserId: null, actorAdminUserId: $adminId, ip: $ip,
        );
    }

    public function recordEnd(ImpersonationToken $t, int $adminId, ?string $ip): void
    {
        SaasAdminAuditLog::create([
            'admin_user_id' => $adminId, 'action' => 'impersonation.end',
            'target_type' => 'tenant', 'target_id' => $t->tenant_id,
            'target_tenant_id' => $t->tenant_id,
            'payload_before' => ['session_ended_at' => null],
            'payload_after' => ['session_ended_at' => now()->toIso8601String()],
            'reason' => $t->reason, 'ip_address' => $ip ?? '127.0.0.1',
            'user_agent' => null,
        ]);
    }
}
```

- [ ] **Step 4: confirm GREEN**
- [ ] **Step 5: commit**

---

## Task 9 — Wire `ImpersonationController::init`

**Files:**

- Modify: `app/app/Http/Controllers/Api/ImpersonationController.php:94-141`
- Test: `app/tests/Feature/Pd/ImpersonationAuditTest.php` (NEW)

- [ ] **Step 1: failing test** — POST /api/admin/impersonation/init → ровно 1 строка `saas_admin_audit_log` с `action=impersonation.init`, reason из body.
- [ ] **Step 2: confirm RED**
- [ ] **Step 3: implement — inject `ImpersonationAuditService` в `init()` после `ImpersonationToken::create`**

```php
$audit->recordInit($token, adminId: $requestedBy, ip: $request->ip());
```

- [ ] **Step 4: confirm GREEN**
- [ ] **Step 5: commit**

---

## Task 10 — Wire `ImpersonationController::verify`

- [ ] **Step 1: failing test** — POST verify → +1 saas_audit (impersonation.verify) + +1 pd_processing_log (purpose=impersonation_session_{id}).
- [ ] **Step 2: RED**
- [ ] **Step 3: implement — после `$token->update(['used_at' => now()])`**

```php
$audit->recordVerify($token, adminId: $token->requested_by, ip: $request->ip());
```

- [ ] **Step 4: GREEN**
- [ ] **Step 5: commit**

---

## Task 11 — Wire `ImpersonationController::end`

- [ ] **Step 1: failing test** — POST end → +1 saas_audit (impersonation.end).
- [ ] **Step 2: RED**
- [ ] **Step 3: implement — после `$token->update(['session_ended_at' => now()])`**

```php
$audit->recordEnd($token, adminId: $token->requested_by, ip: $request->ip());
```

- [ ] **Step 4: GREEN**
- [ ] **Step 5: commit single («feat(audit): impersonation flow writes saas_admin_audit_log + pd_processing_log»)**

---

## Task 12 — Integration test: полный ПДн-цикл

**Files:**

- Create: `app/tests/Feature/Pd/PdFullFlowIntegrationTest.php`

- [ ] **Step 1: test — сценарий «вебхук → создание сделки → просмотр → экспорт → удаление отчёта»**

```php
it('records pd events through entire deal lifecycle', function () {
    // 1. Webhook receive → ProcessWebhookJob (sync) → pd 'created'
    // 2. GET /api/deals/{id} → pd 'viewed'
    // 3. POST /api/reports/jobs (deals_export) → report created → trigger pd 'exported'
    // 4. POST /api/deals/export → pd 'exported' (purpose=deals_export_csv)
    // 5. DELETE /api/reports/jobs/{id} → pd 'deleted'
    // → assert 5 строк в pd_processing_log с правильными action/purpose
});
```

- [ ] **Step 2: RED → GREEN**
- [ ] **Step 3: commit**

---

## Task 13 — Full regression (verification gate)

- [ ] **Step 1: запустить полную регрессию**

```bash
cd app && php artisan test --parallel
cd app && composer pint
cd app && composer stan
```

Expected: всё GREEN; зафиксировать в коммите номер прогона.

- [ ] **Step 2: пометить план DONE в этом же файле**

```bash
git add docs/superpowers/plans/2026-05-22-audit-pd-impersonation.md
git commit -m "docs(plans): mark P0 audit-pd-impersonation DONE"
```

---

## Self-Review

- **Spec coverage:**
  - ПДн `created` — Tasks 3 + 7 (manual / webhook / supplier / import) ✓
  - ПДн `viewed` — Task 2 (deal show) + Task 10 (impersonation session) ✓
  - ПДн `exported` — Task 4 (DealExport напрямую) + триггер на report_jobs (уже есть в схеме) ✓
  - ПДн `deleted` — Task 5 (вручную) + Task 6 (cron) ✓
  - Impersonation audit — Tasks 8/9/10/11 (init/verify/end + service) ✓
  - Impersonation ПДн — внутри Task 10 (hybrid C=c) ✓
- **Placeholder scan:** все шаги содержат реальный код / точные пути / реальные тестовые сценарии; нет «TODO»/«TBD».
- **Type consistency:** `PdAuditLogger->record(...)` сигнатура одинакова во всех вызовах (Task 1 определяет, Tasks 2-7 + 8 используют).
- **Out-of-scope (для отдельных планов):**
  - `user_id`/`ip` в `activity_log` — Plan B.
  - Auth events full coverage — Plan B.
  - Project mutations журнал — Plan C.

---

## Execution

После сохранения этого файла — `superpowers:subagent-driven-development` (рекомендуется) или `superpowers:executing-plans`.
