# G1 / SP1 — Самозапись клиента с подтверждением почты (backend) Implementation Plan (v3)

> **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:** Превратить регистрацию в самозапись: почта+пароль+капча создают новый тенант (`pending_email_confirm`) и владельца, на почту уходит 6-значный код, подтверждение активирует аккаунт, даёт 300 ₽ и выполняет вход.

**Architecture:** Тонкий `RegistrationController` (register/confirm-email/resend) над `RegistrationService`. Сервис пишет/читает `tenants`/`users`/`email_verifications` через BYPASSRLS-подключение `pgsql_supplier` (на публичных роутах нет tenant-GUC). Код подтверждения хранится как bcrypt-хеш в `email_verifications` (TTL 15 мин, 5 попыток) — зеркало `ImpersonationToken`. Капча — через интерфейс `CaptchaVerifier` (dev-драйвер `NullCaptchaVerifier`); реальный Yandex SmartCaptcha подключается позже.

**Tech Stack:** PHP 8.3, Laravel 13, PostgreSQL 16 (RLS, 5 ролей), Pest 4, bcmath/Hash, Mailable.

**v3 = переиздание v2 без изменений содержания** (повторная печать для закрепления frozenPlan/gate2 после async-недосейва; правки наставника круга 2 уже учтены в v2 и сохранены здесь).

---

## Цель

Backend самозаписи клиента с подтверждением почты 6-значным кодом: три публичных эндпоинта (`register`, `confirm-email`, `resend-code`), новая схема кода в `email_verifications`, шов капчи, генерация subdomain, стартовый баланс 300 ₽ при активации. Реквизиты/ИНН/гейт проекта и фронтенд — вне этого плана.

## File Structure

| Файл | Ответственность |
|---|---|
| `app/database/migrations/2026_06_18_120000_add_code_fields_to_email_verifications.php` | +`code_hash`, +`failed_attempts` в `email_verifications` (DDL через `pgsql_supplier`, гранты) |
| `db/schema.sql` | каноничная схема: 2 колонки в `email_verifications` + версия шапки v8.42 |
| `db/CHANGELOG_schema.md` | запись v8.42 |
| `app/app/Models/EmailVerification.php` | Eloquent-модель кода (`isUsable`/`isExpired`) |
| `app/app/Services/Captcha/CaptchaVerifier.php` | интерфейс шва капчи |
| `app/app/Services/Captcha/NullCaptchaVerifier.php` | dev/test-драйвер (конфигурируемый pass/fail) |
| `app/config/services.php` | блок `captcha` |
| `app/app/Providers/AppServiceProvider.php` | биндинг `CaptchaVerifier` + лимитер `auth-register` |
| `app/app/Services/Auth/RegistrationException.php` | доменное исключение с `reason`/`attemptsRemaining` |
| `app/app/Services/Auth/RegistrationService.php` | оркестрация register/confirm/resend + генерация subdomain |
| `app/app/Mail/EmailVerificationCodeMail.php` | письмо с кодом |
| `app/resources/views/emails/email_verification_code.blade.php` | шаблон письма |
| `app/app/Http/Requests/Auth/RegisterRequest.php` | +`captcha_token`, −DB-unique (uniqueness в сервисе) |
| `app/app/Http/Requests/Auth/ConfirmEmailRequest.php` | валидация confirm |
| `app/app/Http/Requests/Auth/ResendCodeRequest.php` | валидация resend |
| `app/app/Http/Controllers/Api/RegistrationController.php` | 3 эндпоинта |
| `app/routes/web.php` | роуты register/confirm-email/resend-code (+throttle) |
| `app/app/Http/Controllers/Api/AuthController.php` | удалить старый `register()` |
| `app/tests/Feature/Auth/RegistrationTest.php` | Pest-критерии D8 |
| `app/tests/Feature/Auth/AuthControllerTest.php` | удалить 3 старых register-теста |

**TDD под стеной:** стена двигает указатель только на УСПЕШНом Bash → «красный» прогон теста нельзя опечатать как шаг (он завесит план). Поэтому: тест-файл пишется ПЕРВЫМ (Task 2, до реализации — TDD-порядок авторства), реализация — следом, миграция тест-БД и зелёные прогоны Pest — в конце (Task 11). Красная фаза подразумевается (тесты написаны до кода), но не опечатывается.

---

### Task 1: Схема — код в `email_verifications`

**Files:**
- Create: `app/database/migrations/2026_06_18_120000_add_code_fields_to_email_verifications.php`
- Modify: `db/schema.sql` — ДВА независимых Edit: (A) блок таблицы `email_verifications` (~стр.615-623) и (B) строка версии в шапке (стр.3). Участки далеко разнесены, не пересекаются; объединить в один Edit нельзя (между ними ~600 строк). Применяются последовательно.
- Modify: `db/CHANGELOG_schema.md`

- [ ] **Step 1.1: Миграция** — `Write` файла:

```php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
    public function up(): void
    {
        // DDL через pgsql_supplier (урок Спека B — prod-роли + избегаем deadlock смешивания
        // соединений). На dev pgsql_supplier = postgres superuser, на prod — crm_supplier_worker.
        $supplier = DB::connection('pgsql_supplier');

        $supplier->statement('ALTER TABLE email_verifications ADD COLUMN IF NOT EXISTS code_hash VARCHAR(255)');
        $supplier->statement('ALTER TABLE email_verifications ADD COLUMN IF NOT EXISTS failed_attempts SMALLINT NOT NULL DEFAULT 0');

        // Самозапись пишет/читает email_verifications через BYPASSRLS-роль (нет tenant-GUC
        // на публичном роуте). Гарантируем права на проде (на dev — superuser, no-op).
        foreach (['crm_app_user', 'crm_supplier_worker', 'crm_migrator', 'crm_admin_user'] as $role) {
            $supplier->statement(<<<SQL
                DO \$\$
                BEGIN
                    IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{$role}') THEN
                        GRANT SELECT, INSERT, UPDATE ON email_verifications TO {$role};
                    END IF;
                END
                \$\$
            SQL);
        }
    }

    public function down(): void
    {
        $supplier = DB::connection('pgsql_supplier');
        $supplier->statement('ALTER TABLE email_verifications DROP COLUMN IF EXISTS failed_attempts');
        $supplier->statement('ALTER TABLE email_verifications DROP COLUMN IF EXISTS code_hash');
    }
};
```

- [ ] **Step 1.2: schema.sql — таблица (Edit A)** — заменить блок:

```sql
CREATE TABLE email_verifications (
    id              BIGSERIAL PRIMARY KEY,
    user_id         BIGINT NOT NULL,                       -- FK добавлен после CREATE TABLE users
    email           VARCHAR(255) NOT NULL,
    token           VARCHAR(255) UNIQUE NOT NULL,
    expires_at      TIMESTAMPTZ NOT NULL,
    verified_at     TIMESTAMPTZ,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);
```

на:

```sql
CREATE TABLE email_verifications (
    id              BIGSERIAL PRIMARY KEY,
    user_id         BIGINT NOT NULL,                       -- FK добавлен после CREATE TABLE users
    email           VARCHAR(255) NOT NULL,
    token           VARCHAR(255) UNIQUE NOT NULL,          -- внутр. uuid строки; секрет — code_hash
    code_hash       VARCHAR(255),                          -- v8.42: bcrypt 6-значного кода самозаписи (G1/SP1)
    failed_attempts SMALLINT NOT NULL DEFAULT 0,           -- v8.42: лимит 5 неверных вводов
    expires_at      TIMESTAMPTZ NOT NULL,
    verified_at     TIMESTAMPTZ,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);
```

- [ ] **Step 1.3: schema.sql — версия шапки (Edit B, не пересекается с 1.2)** — заменить строку:

```
-- Версия: v8.41 (17.06.2026 — F-P1 / 152-ФЗ ретеншен: partial index deals(deleted_at) WHERE deleted_at IS NOT NULL для команды pd:scrub-soft-deleted-deals (анонимизация ПДн soft-deleted сделок по retention-сроку из system_settings). Миграция 2026_06_17_120000, спека docs/superpowers/specs/2026-06-17-fp1-deal-pii-retention-spec.md)
```

на две строки:

```
-- Версия: v8.42 (18.06.2026 — G1/SP1 самозапись клиента: email_verifications +code_hash +failed_attempts (6-значный код подтверждения почты, bcrypt, TTL 15м, 5 попыток). Счётчики таблиц/индексов/RLS/функций/триггеров без изменений. Миграция 2026_06_18_120000, спека docs/superpowers/specs/2026-06-18-g1-sp1-self-registration-email-spec.md)
-- Базовая версия: v8.41 (17.06.2026 — F-P1 / 152-ФЗ ретеншен: partial index deals(deleted_at) WHERE deleted_at IS NOT NULL для команды pd:scrub-soft-deleted-deals (анонимизация ПДн soft-deleted сделок по retention-сроку из system_settings). Миграция 2026_06_17_120000, спека docs/superpowers/specs/2026-06-17-fp1-deal-pii-retention-spec.md)
```

- [ ] **Step 1.4: CHANGELOG** — заменить:

```
## v8.41 (2026-06-17) — F-P1 / 152-ФЗ retention: partial index deals(deleted_at)
```

на:

```
## v8.42 (2026-06-18) — G1/SP1 самозапись клиента: код подтверждения почты в email_verifications

Backend самозаписи клиента (находка go-live G1, под-проект SP1). В таблицу
`email_verifications` добавлены поля под 6-значный код подтверждения почты —
самозапись создаёт тенанта в статусе `pending_email_confirm` и подтверждает
почту кодом (механика зеркалит `impersonation_tokens`).

Спека: `docs/superpowers/specs/2026-06-18-g1-sp1-self-registration-email-spec-v2.md`.
План: `docs/superpowers/plans/2026-06-18-g1-sp1-self-registration-email-plan-v3.md`.
Миграция: `app/database/migrations/2026_06_18_120000_add_code_fields_to_email_verifications.php`.

**Добавлено:**

- **`email_verifications.code_hash`** — `VARCHAR(255)`, bcrypt-хеш 6-значного кода
  (plain в БД не хранится). `token` остаётся внутренним UUID строки.
- **`email_verifications.failed_attempts`** — `SMALLINT NOT NULL DEFAULT 0`, лимит 5
  неверных вводов (как `impersonation_tokens.failed_attempts`). TTL строки — 15 минут
  (`expires_at`). Счётчики таблиц/индексов/RLS/функций/триггеров в шапке схемы — без изменений.
- **GRANT SELECT, INSERT, UPDATE** на `email_verifications` для 4 ролей (самозапись пишет
  через BYPASSRLS — нет tenant-GUC на публичном роуте).

## v8.41 (2026-06-17) — F-P1 / 152-ФЗ retention: partial index deals(deleted_at)
```

---

### Task 2: Pest-критерии (пишем тест ПЕРВЫМ — TDD-порядок авторства)

**Files:**
- Create: `app/tests/Feature/Auth/RegistrationTest.php`

- [ ] **Step 2.1:** `Write` файла:

```php
<?php

declare(strict_types=1);

use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;

// Самозапись пишет tenants/users/email_verifications через BYPASSRLS pgsql_supplier
// (на публичном роуте нет tenant-GUC). SharesSupplierPdo шарит PDO под DatabaseTransactions.
uses(DatabaseTransactions::class, SharesSupplierPdo::class);

beforeEach(function () {
    config(['services.captcha.fake_passes' => true]);
});

function registerPayload(array $over = []): array
{
    return array_merge([
        'email' => 'newclient@example.ru',
        'password' => 'fresh-pass-123',
        'accept_offer' => true,
        'accept_pdn' => true,
        'captcha_token' => 'tok-123',
    ], $over);
}

test('register создаёт pending-тенанта + неактивного владельца + код, без входа', function () {
    $r = $this->postJson('/api/auth/register', registerPayload());

    $r->assertStatus(201);
    $r->assertJsonPath('status', 'pending_email_confirm');
    $r->assertJsonPath('email', 'newclient@example.ru');
    expect($r->json('_dev_plain_code'))->toMatch('/^\d{6}$/');

    $user = User::where('email', 'newclient@example.ru')->first();
    expect($user)->not->toBeNull();
    expect($user->is_active)->toBeFalse();

    $tenant = Tenant::find($user->tenant_id);
    expect($tenant->status)->toBe('pending_email_confirm');
    expect((float) $tenant->balance_rub)->toBe(0.0);

    // Вход НЕ выполнен.
    $this->getJson('/api/auth/me')->assertStatus(401);
});

test('register пишет email_verifications через pgsql_supplier (BYPASSRLS)', function () {
    $connections = [];
    DB::listen(function ($q) use (&$connections) {
        if (str_contains($q->sql, 'email_verifications')) {
            $connections[] = $q->connectionName;
        }
    });

    $this->postJson('/api/auth/register', registerPayload())->assertStatus(201);

    expect($connections)->not->toBeEmpty();
    expect(array_values(array_unique($connections)))->toBe(['pgsql_supplier']);
});

test('register отклоняет неверную капчу (422), аккаунт не создаётся', function () {
    config(['services.captcha.fake_passes' => false]);

    $this->postJson('/api/auth/register', registerPayload())
        ->assertStatus(422)
        ->assertJsonValidationErrors(['captcha_token']);

    expect(User::where('email', 'newclient@example.ru')->exists())->toBeFalse();
});

test('register требует accept_offer, accept_pdn, captcha_token', function () {
    $this->postJson('/api/auth/register', registerPayload(['accept_offer' => false]))
        ->assertStatus(422)->assertJsonValidationErrors(['accept_offer']);

    $this->postJson('/api/auth/register', registerPayload(['accept_pdn' => false]))
        ->assertStatus(422)->assertJsonValidationErrors(['accept_pdn']);

    $payload = registerPayload();
    unset($payload['captcha_token']);
    $this->postJson('/api/auth/register', $payload)
        ->assertStatus(422)->assertJsonValidationErrors(['captcha_token']);
});

test('confirm верным кодом активирует тенанта/владельца, баланс 300, выполняет вход', function () {
    $reg = $this->postJson('/api/auth/register', registerPayload())->assertStatus(201);
    $code = $reg->json('_dev_plain_code');

    $r = $this->postJson('/api/auth/confirm-email', [
        'email' => 'newclient@example.ru',
        'code' => $code,
    ]);

    $r->assertOk();
    $r->assertJsonPath('user.email', 'newclient@example.ru');
    $r->assertJsonPath('requires_2fa', false);

    $user = User::where('email', 'newclient@example.ru')->first();
    expect($user->is_active)->toBeTrue();

    $tenant = Tenant::find($user->tenant_id);
    expect($tenant->status)->toBe('active');
    expect((float) $tenant->balance_rub)->toBe(300.0);
});

test('confirm неверным кодом → 422 + failed_attempts++', function () {
    $reg = $this->postJson('/api/auth/register', registerPayload())->assertStatus(201);
    $real = $reg->json('_dev_plain_code');
    $wrong = $real === '000000' ? '111111' : '000000';

    $r = $this->postJson('/api/auth/confirm-email', [
        'email' => 'newclient@example.ru',
        'code' => $wrong,
    ]);

    $r->assertStatus(422);
    expect($r->json('attempts_remaining'))->toBe(4);
});

test('5 неверных кодов инвалидируют запись (даже верный код больше не проходит)', function () {
    $reg = $this->postJson('/api/auth/register', registerPayload())->assertStatus(201);
    $real = $reg->json('_dev_plain_code');
    $wrong = $real === '000000' ? '111111' : '000000';

    for ($i = 0; $i < 5; $i++) {
        $this->postJson('/api/auth/confirm-email', [
            'email' => 'newclient@example.ru',
            'code' => $wrong,
        ])->assertStatus(422);
    }

    $this->postJson('/api/auth/confirm-email', [
        'email' => 'newclient@example.ru',
        'code' => $real,
    ])->assertStatus(422)->assertJsonPath('reason', 'too_many_attempts');
});

test('протухший код отклоняется, resend выдаёт рабочий', function () {
    $reg = $this->postJson('/api/auth/register', registerPayload())->assertStatus(201);
    $user = User::where('email', 'newclient@example.ru')->first();

    DB::table('email_verifications')->where('user_id', $user->id)
        ->update(['expires_at' => now()->subMinutes(5)]);

    $this->postJson('/api/auth/confirm-email', [
        'email' => 'newclient@example.ru',
        'code' => $reg->json('_dev_plain_code'),
    ])->assertStatus(422)->assertJsonPath('reason', 'expired');

    $resend = $this->postJson('/api/auth/resend-code', ['email' => 'newclient@example.ru'])->assertOk();
    $newCode = $resend->json('_dev_plain_code');
    expect($newCode)->toMatch('/^\d{6}$/');

    $this->postJson('/api/auth/confirm-email', [
        'email' => 'newclient@example.ru',
        'code' => $newCode,
    ])->assertOk();
});

test('повторный register на неподтверждённый email не создаёт второго тенанта', function () {
    $this->postJson('/api/auth/register', registerPayload())->assertStatus(201);
    $this->postJson('/api/auth/register', registerPayload())->assertStatus(201);

    expect(User::where('email', 'newclient@example.ru')->count())->toBe(1);
    expect(Tenant::where('contact_email', 'newclient@example.ru')->count())->toBe(1);
});

test('register на активный email → 422', function () {
    $tenant = Tenant::factory()->create();
    User::factory()->create([
        'tenant_id' => $tenant->id,
        'email' => 'active@example.ru',
        'is_active' => true,
    ]);

    $this->postJson('/api/auth/register', registerPayload(['email' => 'active@example.ru']))
        ->assertStatus(422)->assertJsonValidationErrors(['email']);
});

test('resend на несуществующий email → унифицированный ответ (anti-enumeration)', function () {
    $this->postJson('/api/auth/resend-code', ['email' => 'nobody@example.ru'])
        ->assertOk()
        ->assertJsonMissingPath('_dev_plain_code');
});
```

---

### Task 3: Модель `EmailVerification`

**Files:**
- Create: `app/app/Models/EmailVerification.php`

- [ ] **Step 3.1:** `Write`:

```php
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;

/**
 * Код подтверждения почты при самозаписи клиента (G1/SP1).
 *
 * 6-значный код, bcrypt-хеш в code_hash, plain уходит письмом. TTL 15 мин,
 * 5 попыток. Механика зеркалит ImpersonationToken. Таблица RLS-изолирована
 * (через user_id→users.tenant_id) — на публичном роуте читается/пишется через
 * BYPASSRLS pgsql_supplier.
 *
 * @property int $id
 * @property int $user_id
 * @property string $email
 * @property string $token
 * @property string|null $code_hash
 * @property int $failed_attempts
 * @property Carbon $expires_at
 * @property Carbon|null $verified_at
 * @property Carbon $created_at
 */
class EmailVerification extends Model
{
    /** schema-таблица не имеет updated_at. */
    public const UPDATED_AT = null;

    protected $fillable = [
        'user_id',
        'email',
        'token',
        'code_hash',
        'failed_attempts',
        'expires_at',
        'verified_at',
    ];

    protected function casts(): array
    {
        return [
            'failed_attempts' => 'integer',
            'expires_at' => 'datetime',
            'verified_at' => 'datetime',
            'created_at' => 'datetime',
        ];
    }

    public function isExpired(): bool
    {
        return $this->expires_at->isPast();
    }

    public function isUsable(): bool
    {
        return $this->verified_at === null
            && $this->failed_attempts < 5
            && ! $this->isExpired();
    }

    /** @return BelongsTo<User, $this> */
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}
```

---

### Task 4: Шов капчи

**Files:**
- Create: `app/app/Services/Captcha/CaptchaVerifier.php`
- Create: `app/app/Services/Captcha/NullCaptchaVerifier.php`
- Modify: `app/config/services.php`

- [ ] **Step 4.1: Интерфейс** — `Write`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Captcha;

/**
 * Шов проверки капчи. SP1 — dev-драйвер NullCaptchaVerifier; реальный
 * Yandex SmartCaptcha-драйвер подключается позже (SP3/ops) без изменения
 * контроллера/сервиса — они зовут только этот интерфейс.
 */
interface CaptchaVerifier
{
    /** true — капча пройдена; false — отклонить регистрацию. */
    public function verify(?string $token, ?string $ip = null): bool;
}
```

- [ ] **Step 4.2: Dev-драйвер** — `Write`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Captcha;

/**
 * Dev/test-драйвер: пустой токен — всегда провал; непустой — исход из конфига
 * services.captcha.fake_passes (тест переключает на false, чтобы проверить
 * ветку отклонения). На prod вместо него встанет Yandex SmartCaptcha-драйвер.
 */
final class NullCaptchaVerifier implements CaptchaVerifier
{
    public function verify(?string $token, ?string $ip = null): bool
    {
        if ($token === null || $token === '') {
            return false;
        }

        return (bool) config('services.captcha.fake_passes', true);
    }
}
```

- [ ] **Step 4.3: config/services.php** — `Edit`. Заменить:

```php
    'supplier' => [
        'login' => env('SUPPLIER_LOGIN'),
```

на:

```php
    // Капча самозаписи (G1/SP1). driver=null → NullCaptchaVerifier (dev/test).
    // Реальный Yandex SmartCaptcha подключается позже (SP3/ops).
    'captcha' => [
        'driver' => env('CAPTCHA_DRIVER', 'null'),
        'fake_passes' => filter_var(env('CAPTCHA_FAKE_PASSES', true), FILTER_VALIDATE_BOOL),
        'yandex_server_key' => env('YANDEX_SMARTCAPTCHA_SERVER_KEY'),
    ],

    'supplier' => [
        'login' => env('SUPPLIER_LOGIN'),
```

---

### Task 5: Биндинг капчи + лимитер `auth-register`

**Files:**
- Modify: `app/app/Providers/AppServiceProvider.php` — ДВА независимых Edit: (A) добавить биндинг в конец метода `register()`; (B) добавить `'auth-register'` в массив лимитеров в `boot()`. Разные методы класса, не пересекаются; применяются последовательно без конфликта.

- [ ] **Step 5.1: Биндинг (Edit A — метод register())** — заменить:

```php
            ),
        );
    }

    /**
     * Bootstrap any application services.
     */
```

на:

```php
            ),
        );

        // Шов капчи самозаписи (G1/SP1). SP1 — Null-драйвер; реальный
        // Yandex SmartCaptcha встанет сюда позже через match по драйверу.
        $this->app->bind(
            \App\Services\Captcha\CaptchaVerifier::class,
            \App\Services\Captcha\NullCaptchaVerifier::class,
        );
    }

    /**
     * Bootstrap any application services.
     */
```

- [ ] **Step 5.2: Лимитер (Edit B — метод boot(), не пересекается с 5.1)** — заменить:

```php
        foreach (['auth-login', 'auth-2fa', 'auth-password'] as $limiterName) {
```

на:

```php
        foreach (['auth-login', 'auth-2fa', 'auth-password', 'auth-register'] as $limiterName) {
```

---

### Task 6: Письмо с кодом

**Files:**
- Create: `app/app/Mail/EmailVerificationCodeMail.php`
- Create: `app/resources/views/emails/email_verification_code.blade.php`

- [ ] **Step 6.1: Mailable** — `Write`:

```php
<?php

declare(strict_types=1);

namespace App\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

/**
 * Письмо с 6-значным кодом подтверждения почты при самозаписи (G1/SP1).
 */
final class EmailVerificationCodeMail extends Mailable
{
    use Queueable;
    use SerializesModels;

    public function __construct(
        public readonly string $code,
        public readonly string $email,
    ) {}

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: 'Код подтверждения регистрации в Лидерре',
            to: [$this->email],
        );
    }

    public function content(): Content
    {
        return new Content(
            view: 'emails.email_verification_code',
            with: ['code' => $this->code],
        );
    }
}
```

- [ ] **Step 6.2: Шаблон** — `Write` `app/resources/views/emails/email_verification_code.blade.php`:

```blade
<p>Здравствуйте!</p>
<p>Ваш код подтверждения регистрации в Лидерре:</p>
<p style="font-size:24px;font-weight:bold;letter-spacing:3px;">{{ $code }}</p>
<p>Код действует 15 минут. Если вы не регистрировались — просто проигнорируйте это письмо.</p>
```

---

### Task 7: Доменное исключение + сервис

**Files:**
- Create: `app/app/Services/Auth/RegistrationException.php`
- Create: `app/app/Services/Auth/RegistrationService.php`

- [ ] **Step 7.1: Исключение** — `Write`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Auth;

use RuntimeException;

/**
 * Доменная ошибка самозаписи. reason — машинный код для ответа контроллера:
 * email_taken | captcha_failed | not_found | expired | too_many_attempts | invalid_code.
 */
final class RegistrationException extends RuntimeException
{
    public function __construct(
        public readonly string $reason,
        public readonly ?int $attemptsRemaining = null,
    ) {
        parent::__construct($reason);
    }
}
```

- [ ] **Step 7.2: Сервис** — `Write`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Auth;

use App\Mail\EmailVerificationCodeMail;
use App\Models\EmailVerification;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Captcha\CaptchaVerifier;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;

/**
 * Оркестрация самозаписи (G1/SP1): register / confirm / resend.
 *
 * Все обращения к tenants/users/email_verifications — через BYPASSRLS-подключение
 * pgsql_supplier: публичные роуты не выставляют app.current_tenant_id, и под RLS
 * (роль crm_app_user) SELECT/INSERT по этим таблицам не прошёл бы.
 */
class RegistrationService
{
    private const DB_CONNECTION = 'pgsql_supplier';

    private const CODE_TTL_MINUTES = 15;

    private const MAX_FAILED_ATTEMPTS = 5;

    private const START_BALANCE_RUB = '300.00';

    public function __construct(private readonly CaptchaVerifier $captcha) {}

    /**
     * @return array{status:string, user:User, verification:EmailVerification, dev_code:?string}
     */
    public function register(string $email, string $password, ?string $captchaToken, ?string $ip): array
    {
        if (! $this->captcha->verify($captchaToken, $ip)) {
            throw new RegistrationException('captcha_failed');
        }

        $email = mb_strtolower(trim($email));
        $existing = User::on(self::DB_CONNECTION)->where('email', $email)->first();

        if ($existing && $existing->is_active) {
            throw new RegistrationException('email_taken');
        }

        if ($existing && ! $existing->is_active) {
            // Неподтверждённый аккаунт → перевыпуск кода, без дубля тенанта/юзера.
            $issued = $this->issueCode($existing);

            return [
                'status' => 'pending_email_confirm',
                'user' => $existing,
                'verification' => $issued['record'],
                'dev_code' => $issued['dev_code'],
            ];
        }

        $tenant = Tenant::on(self::DB_CONNECTION)->create([
            'subdomain' => $this->generateSubdomain($email),
            'organization_name' => $email,   // плейсхолдер; уточняется в SP2 (реквизиты)
            'contact_email' => $email,
            'status' => 'pending_email_confirm',
            'balance_rub' => 0,
            'is_trial' => true,
        ]);

        $user = User::on(self::DB_CONNECTION)->create([
            'tenant_id' => $tenant->id,
            'email' => $email,
            'password_hash' => Hash::make($password),
            'first_name' => 'Новый',
            'last_name' => 'клиент',
            'is_active' => false,
            'totp_enabled' => false,
        ]);

        $issued = $this->issueCode($user);

        return [
            'status' => 'pending_email_confirm',
            'user' => $user,
            'verification' => $issued['record'],
            'dev_code' => $issued['dev_code'],
        ];
    }

    public function confirm(string $email, string $code): User
    {
        $email = mb_strtolower(trim($email));
        $user = User::on(self::DB_CONNECTION)->where('email', $email)->first();
        if (! $user) {
            throw new RegistrationException('not_found');
        }

        $record = EmailVerification::on(self::DB_CONNECTION)
            ->where('user_id', $user->id)
            ->whereNull('verified_at')
            ->orderByDesc('id')
            ->first();

        if (! $record || ! $record->isUsable()) {
            $reason = $record === null ? 'not_found'
                : ($record->isExpired() ? 'expired' : 'too_many_attempts');
            throw new RegistrationException($reason);
        }

        if (! Hash::check($code, (string) $record->code_hash)) {
            $record->increment('failed_attempts');
            throw new RegistrationException(
                'invalid_code',
                max(0, self::MAX_FAILED_ATTEMPTS - $record->failed_attempts),
            );
        }

        $record->update(['verified_at' => now()]);
        $user->update(['is_active' => true]);
        Tenant::on(self::DB_CONNECTION)->where('id', $user->tenant_id)->update([
            'status' => 'active',
            'balance_rub' => self::START_BALANCE_RUB,
        ]);

        return $user->fresh();
    }

    /** @return ?string dev-код (только local/testing), иначе null. Anti-enumeration: тихо для active/missing. */
    public function resend(string $email): ?string
    {
        $email = mb_strtolower(trim($email));
        $user = User::on(self::DB_CONNECTION)->where('email', $email)->first();

        if ($user && ! $user->is_active) {
            return $this->issueCode($user)['dev_code'];
        }

        return null;
    }

    /**
     * @return array{record:EmailVerification, dev_code:?string}
     */
    private function issueCode(User $user): array
    {
        // Гасим прежние непогашенные коды этого пользователя (делаем неюзабельными).
        EmailVerification::on(self::DB_CONNECTION)
            ->where('user_id', $user->id)
            ->whereNull('verified_at')
            ->update(['failed_attempts' => self::MAX_FAILED_ATTEMPTS]);

        $plain = (string) random_int(100_000, 999_999);

        $record = EmailVerification::on(self::DB_CONNECTION)->create([
            'user_id' => $user->id,
            'email' => $user->email,
            'token' => (string) Str::uuid(),
            'code_hash' => Hash::make($plain),
            'failed_attempts' => 0,
            'expires_at' => now()->addMinutes(self::CODE_TTL_MINUTES),
        ]);

        Mail::to($user->email)->send(new EmailVerificationCodeMail($plain, $user->email));

        return [
            'record' => $record,
            'dev_code' => app()->environment('local', 'testing') ? $plain : null,
        ];
    }

    private function generateSubdomain(string $email): string
    {
        $base = Str::of($email)->before('@')->lower()->replaceMatches('/[^a-z0-9]/', '')->value();
        if ($base === '') {
            $base = 'client';
        }
        $base = Str::limit($base, 50, '');

        $candidate = $base;
        $i = 0;
        while (Tenant::on(self::DB_CONNECTION)->where('subdomain', $candidate)->exists()) {
            $i++;
            $candidate = $base.$i;
        }

        return Str::limit($candidate, 63, '');
    }
}
```

---

### Task 8: Form Requests

**Files:**
- Modify: `app/app/Http/Requests/Auth/RegisterRequest.php`
- Create: `app/app/Http/Requests/Auth/ConfirmEmailRequest.php`
- Create: `app/app/Http/Requests/Auth/ResendCodeRequest.php`

- [ ] **Step 8.1: RegisterRequest** — `Edit`. Заменить методы `rules()` и `messages()`:

```php
    /** @return array<string, mixed> */
    public function rules(): array
    {
        return [
            'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
            'password' => $this->passwordRules(),
            'accept_offer' => ['required', 'accepted'],
            'accept_pdn' => ['required', 'accepted'],
        ];
    }

    /** @return array<string, string> */
    public function messages(): array
    {
        return array_merge($this->passwordMessages(), [
            'email.required' => 'Укажите email.',
            'email.email' => 'Email указан некорректно.',
            'email.unique' => 'Аккаунт с таким email уже существует.',
            'accept_offer.accepted' => 'Необходимо принять оферту.',
            'accept_pdn.accepted' => 'Необходимо согласие на обработку персональных данных.',
        ]);
    }
```

на:

```php
    /**
     * @return array<string, mixed>
     *
     * NB: уникальность email НЕ через DB-rule — её решает RegistrationService
     * (активный email → 422 email_taken; неподтверждённый → перевыпуск кода).
     * Капча проверяется на КАЖДОМ register-запросе (это независимый публичный POST).
     */
    public function rules(): array
    {
        return [
            'email' => ['required', 'string', 'email', 'max:255'],
            'password' => $this->passwordRules(),
            'accept_offer' => ['required', 'accepted'],
            'accept_pdn' => ['required', 'accepted'],
            'captcha_token' => ['required', 'string'],
        ];
    }

    /** @return array<string, string> */
    public function messages(): array
    {
        return array_merge($this->passwordMessages(), [
            'email.required' => 'Укажите email.',
            'email.email' => 'Email указан некорректно.',
            'accept_offer.accepted' => 'Необходимо принять оферту.',
            'accept_pdn.accepted' => 'Необходимо согласие на обработку персональных данных.',
            'captcha_token.required' => 'Подтвердите, что вы не робот.',
        ]);
    }
```

> NB: после этого Edit импорт `use Illuminate\Validation\Rule;` в RegisterRequest становится неиспользуемым — `composer pint` уберёт его перед коммитом (Шаг финализации).

- [ ] **Step 8.2: ConfirmEmailRequest** — `Write`:

```php
<?php

declare(strict_types=1);

namespace App\Http\Requests\Auth;

use Illuminate\Foundation\Http\FormRequest;

/**
 * Валидация POST /api/auth/confirm-email — подтверждение почты 6-значным кодом.
 */
class ConfirmEmailRequest extends FormRequest
{
    /** @return array<string, mixed> */
    public function rules(): array
    {
        return [
            'email' => ['required', 'string', 'email', 'max:255'],
            'code' => ['required', 'string', 'regex:/^\d{6}$/'],
        ];
    }

    /** @return array<string, string> */
    public function messages(): array
    {
        return [
            'email.required' => 'Укажите email.',
            'code.required' => 'Укажите код из письма.',
            'code.regex' => 'Код состоит из 6 цифр.',
        ];
    }
}
```

- [ ] **Step 8.3: ResendCodeRequest** — `Write`:

```php
<?php

declare(strict_types=1);

namespace App\Http\Requests\Auth;

use Illuminate\Foundation\Http\FormRequest;

/**
 * Валидация POST /api/auth/resend-code — повторная отправка кода подтверждения.
 */
class ResendCodeRequest extends FormRequest
{
    /** @return array<string, mixed> */
    public function rules(): array
    {
        return [
            'email' => ['required', 'string', 'email', 'max:255'],
        ];
    }

    /** @return array<string, string> */
    public function messages(): array
    {
        return [
            'email.required' => 'Укажите email.',
            'email.email' => 'Email указан некорректно.',
        ];
    }
}
```

---

### Task 9: Контроллер + роуты

**Files:**
- Create: `app/app/Http/Controllers/Api/RegistrationController.php`
- Modify: `app/routes/web.php`
- Modify: `app/app/Http/Controllers/Api/AuthController.php`

- [ ] **Step 9.1: RegistrationController** — `Write`:

```php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Concerns\WritesAuthLog;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\ConfirmEmailRequest;
use App\Http\Requests\Auth\RegisterRequest;
use App\Http\Requests\Auth\ResendCodeRequest;
use App\Models\User;
use App\Services\Auth\RegistrationException;
use App\Services\Auth\RegistrationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;

/**
 * Самозапись клиента (G1/SP1): register → confirm-email → (вход).
 * Подтверждение почты 6-значным кодом; новый тенант создаётся в статусе
 * pending_email_confirm, активируется и получает 300 ₽ при подтверждении.
 */
class RegistrationController extends Controller
{
    use WritesAuthLog;

    public function register(RegisterRequest $request, RegistrationService $service): JsonResponse
    {
        try {
            $result = $service->register(
                $request->string('email')->toString(),
                $request->string('password')->toString(),
                $request->input('captcha_token'),
                $request->ip(),
            );
        } catch (RegistrationException $e) {
            return $this->registrationError($e);
        }

        $payload = [
            'status' => $result['status'],
            'email' => $result['user']->email,
            'expires_at' => $result['verification']->expires_at->toIso8601String(),
        ];
        if ($result['dev_code'] !== null) {
            $payload['_dev_plain_code'] = $result['dev_code'];
        }

        return response()->json($payload, 201);
    }

    public function confirmEmail(ConfirmEmailRequest $request, RegistrationService $service): JsonResponse
    {
        try {
            $user = $service->confirm(
                $request->string('email')->toString(),
                $request->string('code')->toString(),
            );
        } catch (RegistrationException $e) {
            $payload = ['message' => 'Код подтверждения недействителен.', 'reason' => $e->reason];
            if ($e->attemptsRemaining !== null) {
                $payload['attempts_remaining'] = $e->attemptsRemaining;
            }

            return response()->json($payload, 422);
        }

        Auth::login($user);
        $request->session()->regenerate();
        $this->logAuthEvent('register_success', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), null);

        return response()->json([
            'user' => $this->userResource($user),
            'requires_2fa' => false,
        ]);
    }

    public function resendCode(ResendCodeRequest $request, RegistrationService $service): JsonResponse
    {
        $devCode = $service->resend($request->string('email')->toString());

        $payload = ['message' => 'Если аккаунт ожидает подтверждения, мы отправили новый код на указанный email.'];
        if ($devCode !== null) {
            $payload['_dev_plain_code'] = $devCode;
        }

        return response()->json($payload);
    }

    private function registrationError(RegistrationException $e): JsonResponse
    {
        $map = [
            'captcha_failed' => ['captcha_token', 'Проверка «я не робот» не пройдена.'],
            'email_taken' => ['email', 'Аккаунт с таким email уже существует.'],
        ];
        [$field, $message] = $map[$e->reason] ?? ['email', 'Не удалось зарегистрировать аккаунт.'];

        return response()->json([
            'message' => $message,
            'errors' => [$field => [$message]],
        ], 422);
    }

    /** @return array<string, mixed> */
    private function userResource(User $user): array
    {
        return [
            'id' => $user->id,
            'email' => $user->email,
            'first_name' => $user->first_name,
            'last_name' => $user->last_name,
            'phone' => $user->phone,
            'timezone' => $user->timezone,
            'tenant_id' => $user->tenant_id,
            'totp_enabled' => $user->totp_enabled,
            'last_login_at' => $user->last_login_at,
            'notification_preferences' => $user->notification_preferences,
            'sound_enabled' => $user->sound_enabled,
        ];
    }
}
```

- [ ] **Step 9.2: Роуты** — `Edit` `app/routes/web.php`. Заменить:

```php
    Route::post('/register', 'App\Http\Controllers\Api\AuthController@register');
```

на:

```php
    Route::post('/register', 'App\Http\Controllers\Api\RegistrationController@register')
        ->middleware('throttle:auth-register');
    Route::post('/confirm-email', 'App\Http\Controllers\Api\RegistrationController@confirmEmail')
        ->middleware('throttle:auth-register');
    Route::post('/resend-code', 'App\Http\Controllers\Api\RegistrationController@resendCode')
        ->middleware('throttle:auth-register');
```

- [ ] **Step 9.3: AuthController** — `Edit` (удалить старый `register()`). Найти и заменить блок на пустую строку:

```php
    public function register(RegisterRequest $request): JsonResponse
    {
        // На MVP — attach нового user'а к первому tenant'у (для UI-разводки).
        // Production: wizard с tenant_name + ИНН + создание Tenant + первый user owner-роли.
        $tenant = Tenant::first();
        if (! $tenant) {
            return response()->json([
                'message' => 'Tenants не настроены. Обратитесь к администратору.',
            ], 503);
        }

        $user = User::create([
            'tenant_id' => $tenant->id,
            'email' => $request->string('email')->toString(),
            'password_hash' => Hash::make($request->string('password')->toString()),
            'first_name' => 'Новый',
            'last_name' => 'Пользователь',
            'is_active' => true,
            'totp_enabled' => false,
        ]);

        Auth::login($user);
        $request->session()->regenerate();

        $this->logAuthEvent('register_success', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), null);

        return response()->json([
            'user' => $this->userResource($user),
            'requires_2fa' => false,
        ], 201);
    }

```

(Импорты `RegisterRequest` и `Tenant` станут неиспользуемыми — уберёт `composer pint` перед коммитом.)

---

### Task 10: Убрать устаревшие register-тесты из AuthControllerTest

**Files:**
- Modify: `app/tests/Feature/Auth/AuthControllerTest.php`

- [ ] **Step 10.1:** `Edit` — удалить 3 теста старого контракта register (они переехали в RegistrationTest с новым контрактом). Заменить блок на пустую строку:

```php
test('POST /api/auth/register создаёт user + возвращает 201', function () {
    $response = $this->postJson('/api/auth/register', [
        'email' => 'new-signup@example.ru',
        'password' => 'fresh-pass-123',
        'accept_offer' => true,
        'accept_pdn' => true,
    ]);

    $response->assertStatus(201);
    $response->assertJsonPath('user.email', 'new-signup@example.ru');
    $response->assertJsonPath('requires_2fa', false);

    $user = User::where('email', 'new-signup@example.ru')->first();
    expect($user)->not->toBeNull();
    expect(Hash::check('fresh-pass-123', $user->password_hash))->toBeTrue();
});

test('POST /api/auth/register отвергает существующий email (unique)', function () {
    User::factory()->create([
        'tenant_id' => $this->tenant->id,
        'email' => 'duplicate@example.ru',
    ]);

    $response = $this->postJson('/api/auth/register', [
        'email' => 'duplicate@example.ru',
        'password' => 'any-password-123',
        'accept_offer' => true,
        'accept_pdn' => true,
    ]);

    $response->assertStatus(422);
    $response->assertJsonValidationErrors(['email']);
});

test('POST /api/auth/register требует accept_offer=true И accept_pdn=true (ТЗ §1.5/§4.1)', function () {
    $base = [
        'email' => 'no-consent@example.ru',
        'password' => 'fresh-pass-123',
    ];

    // Без оферты.
    $this->postJson('/api/auth/register', array_merge($base, ['accept_pdn' => true]))
        ->assertStatus(422)
        ->assertJsonValidationErrors(['accept_offer']);

    // Без ПДн.
    $this->postJson('/api/auth/register', array_merge($base, ['accept_offer' => true]))
        ->assertStatus(422)
        ->assertJsonValidationErrors(['accept_pdn']);
});

```

(Register-контракт целиком покрыт `RegistrationTest.php`.)

---

### Task 11: Миграция тест-БД + зелёные прогоны + финализация

**Files:** —

- [ ] **Step 11.1: Применить миграцию к тест-БД `liderra_testing`** — `Bash` (опечатанный шаг; одиночная команда с env-префиксом, без цепочек; `migrate` — floor-safe, не `migrate:fresh`):

Run: `DB_DATABASE=liderra_testing php app/artisan migrate --force`
Expected: `INFO  Running migrations.` + `... 2026_06_18_120000_add_code_fields_to_email_verifications ... DONE`. Миграция идемпотентна (`ADD COLUMN IF NOT EXISTS`).

- [ ] **Step 11.2: Зелёный прогон RegistrationTest** — `Bash`:

Run: `composer --working-dir=app test -- tests/Feature/Auth/RegistrationTest.php`
Expected: PASS (12 тестов зелёные).

- [ ] **Step 11.3: Зелёный прогон AuthControllerTest** (регресс — register-тесты убраны) — `Bash`:

Run: `composer --working-dir=app test -- tests/Feature/Auth/AuthControllerTest.php`
Expected: PASS (login/me/logout без register-блока).

- [ ] **Step 11.4: Финализация (терминал владельца):** `composer --working-dir=app pint` (уберёт неиспользуемые импорты в RegisterRequest/AuthController) + миграция dev-БД (`php app/artisan migrate --force` с обычным `.env`), затем коммит:

```powershell
$env:LEFTHOOK="0"; git add app/ db/ docs/superpowers/specs/2026-06-18-g1-sp1-self-registration-email-spec-v2.md docs/superpowers/plans/2026-06-18-g1-sp1-self-registration-email-plan-v3.md; git commit -m "feat: G1/SP1 самозапись клиента с подтверждением почты 6-значным кодом" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"; $env:LEFTHOOK=$null
```

---

## Self-Review

**Spec coverage:** D1 register → Task 7/8/9; D2 confirm → Task 7/9; D3 resend → Task 7/9; D4 схема → Task 1 + модель Task 3; D5 капча → Task 4/5; D6 subdomain → `generateSubdomain` Task 7; D7 edge/безопасность (BYPASSRLS, hash, throttle, баланс при активации) → Task 1/5/7; D8 критерии → Task 2 (+ регресс Task 10). Все секции покрыты.

**Placeholder scan:** нет TBD/«позже-доделаем» в коде; Yandex SmartCaptcha — осознанный отложенный драйвер, интерфейс готов.

**Type consistency:** `RegistrationService` методы (`register`/`confirm`/`resend`/`issueCode`/`generateSubdomain`), `RegistrationException(reason, attemptsRemaining)`, `CaptchaVerifier::verify(?string,?string)`, `EmailVerification::isUsable()` — имена совпадают во всех тасках. `_dev_plain_code` единообразно в register/resend и тестах. Reason-коды (`captcha_failed`/`email_taken`/`expired`/`too_many_attempts`/`invalid_code`/`not_found`) согласованы сервис↔контроллер↔тесты.

```skills-json
["test-driven-development"]
```

```steps-json
[
  {"op":"Write","object":"app/database/migrations/2026_06_18_120000_add_code_fields_to_email_verifications.php","ref":"D4"},
  {"op":"Edit","object":"db/schema.sql","ref":"D4"},
  {"op":"Edit","object":"db/schema.sql","ref":"D4"},
  {"op":"Edit","object":"db/CHANGELOG_schema.md","ref":"D4"},
  {"op":"Write","object":"app/tests/Feature/Auth/RegistrationTest.php","ref":"D8"},
  {"op":"Write","object":"app/app/Models/EmailVerification.php","ref":"D4"},
  {"op":"Write","object":"app/app/Services/Captcha/CaptchaVerifier.php","ref":"D5"},
  {"op":"Write","object":"app/app/Services/Captcha/NullCaptchaVerifier.php","ref":"D5"},
  {"op":"Edit","object":"app/config/services.php","ref":"D5"},
  {"op":"Edit","object":"app/app/Providers/AppServiceProvider.php","ref":"D5"},
  {"op":"Edit","object":"app/app/Providers/AppServiceProvider.php","ref":"D7"},
  {"op":"Write","object":"app/app/Mail/EmailVerificationCodeMail.php","ref":"D1"},
  {"op":"Write","object":"app/resources/views/emails/email_verification_code.blade.php","ref":"D1"},
  {"op":"Write","object":"app/app/Services/Auth/RegistrationException.php","ref":"D1"},
  {"op":"Write","object":"app/app/Services/Auth/RegistrationService.php","ref":"D1"},
  {"op":"Edit","object":"app/app/Http/Requests/Auth/RegisterRequest.php","ref":"D1"},
  {"op":"Write","object":"app/app/Http/Requests/Auth/ConfirmEmailRequest.php","ref":"D2"},
  {"op":"Write","object":"app/app/Http/Requests/Auth/ResendCodeRequest.php","ref":"D3"},
  {"op":"Write","object":"app/app/Http/Controllers/Api/RegistrationController.php","ref":"D1"},
  {"op":"Edit","object":"app/routes/web.php","ref":"D1"},
  {"op":"Edit","object":"app/app/Http/Controllers/Api/AuthController.php","ref":"D1"},
  {"op":"Edit","object":"app/tests/Feature/Auth/AuthControllerTest.php","ref":"D8"},
  {"op":"Bash","object":"DB_DATABASE=liderra_testing php app/artisan migrate --force","ref":"D4"},
  {"op":"Bash","object":"composer --working-dir=app test -- tests/Feature/Auth/RegistrationTest.php","ref":"D8"},
  {"op":"Bash","object":"composer --working-dir=app test -- tests/Feature/Auth/AuthControllerTest.php","ref":"D8"}
]
```

```verified-context-json
[
  {"id":"D4","kind":"EXTRACTED","ref":"app/app/Models/ImpersonationToken.php","anchor":"failed_attempts < 5"},
  {"id":"D8","kind":"EXTRACTED","ref":"app/tests/Concerns/SharesSupplierPdo.php","anchor":"setReadPdo"},
  {"id":"D7","kind":"EXTRACTED","ref":"app/app/Providers/AppServiceProvider.php","anchor":"auth-login"}
]
```

## Переговоры

### Круг 1

- **Капча на повторном register:** капча проверяется на КАЖДОМ вызове `register` — это независимый публичный POST. Путь «повторный register на неподтверждённый email → перевыпуск кода» проходит ту же проверку капчи (см. `RegistrationService::register`: `captcha->verify` стоит ПЕРВЫМ, до ветки resend). Голый `resend-code` — отдельный эндпоинт без капчи, с anti-enumeration и своим throttle.
- **Зелёные-только Bash под стеной:** стена двигает указатель лишь на успешном Bash → красные TDD-прогоны не опечатываются. Тест-файл написан первым шагом-Write (Task 2), реализация — следом, миграция и зелёные прогоны — в конце.

### Круг 2 (учтено из разбора наставника v1)

- **Явный шаг миграции тест-БД** добавлен опечатанным Bash-шагом 11.1 `DB_DATABASE=liderra_testing php app/artisan migrate --force` перед зелёными прогонами. Одиночная команда с env-префиксом (без `;`/`&&`-цепочек), `migrate` floor-safe (не `migrate:fresh`). Оговорка-риск: если `pgsql_supplier` в конфиге имеет свою БД-переменную ≠ `DB_DATABASE`, DDL уйдёт не в `liderra_testing` (шаг вернёт exit 0, но прогон 11.2 упадёт) — фолбэк: владелец применяет миграцию в терминале + `FLOOR-ESCAPE: plan-done`. На практике `pgsql_supplier` делит `DB_DATABASE`.
- **Двойной Edit `schema.sql`** — Edit A (Step 1.2) блок `CREATE TABLE email_verifications` (~стр.615-623), Edit B (Step 1.3) строка версии шапки (стр.3); ~600 строк между, не пересекаются, применяются последовательно.
- **Двойной Edit `AppServiceProvider.php`** — Edit A (Step 5.1) биндинг в `register()`, Edit B (Step 5.2) массив лимитеров в `boot()`; разные методы, не пересекаются.

### Круг 3 (переиздание v3)

- v3 — байт-эквивалент v2 по содержанию; цель — закрепить frozenPlan/gate2 после async-недосейва печати (артефакт-спека переиздана как spec-v2 и получила свежий gate1 GO). Новых изменений в коде/шагах нет.
