# Supplier Integration — Plan 2.6 cleanup CV.11 audit остатков

> **✅ Plan completed (10.05.2026 поздняя ночь финал).** Все 4 fix'а закрыты атомарными commit'ами на main: `e71a02e` (#i deploy validator), `f78a855` (#ii IP allowlist production fail-closed), `451a294` (#iii timestamp partition guard ±24h), `7899071` (#iv crm_supplier_worker BYPASSRLS-роль). Pest 558/556 (+9 от Plan 2.5 baseline 549/547), Larastan + Pint + squawk clean. Memory updated (`project_supplier_integration.md`, `project_state.md`, `feedback_environment.md` quirk #57). Plan 3 backlog: BLOCKER #6 (RLS на `failed_webhook_jobs`) первая задача + 5 minor WARN + 5 NIT.
>
> **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:** Закрыть 4 production-impact остатка CV.11 audit Plan 2: 2 operational deploy gates (placeholder secret + empty IP allowlist в production) + 1 code-bug (Carbon timestamp partition crash) + 1 архитектурный (BYPASSRLS-роль для queue worker — вариант C из Plan 2.6 brainstorm).

**Architecture:** Все 4 fix'а — атомарные independent изменения, каждый закрыт отдельным commit'ом.

- **Fix #i (placeholder secret):** новая Console-команда `supplier:check-webhook-secret` для deploy-time gate, exit 1 если seed = `'__SET_ON_DEPLOY__'` или len < 32.
- **Fix #ii (empty IP allowlist):** в `SupplierWebhookController::verifyIpAllowlist` — `if ($list === []) return !app()->environment('production')` — production fail-closed, dev/testing fail-open.
- **Fix #iii (timestamp partition guard):** в `SupplierWebhookController::receive` — `'time' => 'integer|min:<24h_ago>|max:<24h_future>'`; защита от старого/будущего timestamp → `no partition for row` CRASH.
- **Fix #iv (RLS под crm_app_user):** новая PG-роль `crm_supplier_worker` (BYPASSRLS) в `db/00_create_roles.sql` для queue worker; обновить inline-warnings в `LeadRouter.php` + `ResetDeliveredTodayCommand.php`; зафиксировать в memory.

**Tech Stack:** Laravel 13.7 (Console + Validator + AppEnvironment), PostgreSQL 16 (CREATE ROLE + GRANT), Pest 4, Larastan, Pint.

**Spec:** [docs/superpowers/specs/2026-05-10-supplier-integration-design.md](../specs/2026-05-10-supplier-integration-design.md).

**Plan основан на:** CV.11 code-reviewer subagent audit Plan 2 (10.05.2026 поздняя ночь) — 6 BLOCKER findings из которых #2 + #3 закрыты Plan 2.5 (`1ba1df8` + `c1ae195`); #6 → Plan 3 первая задача; #4/#5 + 8 minor WARN + 5 NIT — частично здесь, частично в Plan 3 NOTES. Полные findings — `memory/project_supplier_integration.md` секция «Pending CV.11 audit findings → Plan 3 backlog».

---

## File Map

**New files:**

- `app/app/Console/Commands/CheckSupplierWebhookSecretCommand.php`
- `app/tests/Feature/Console/CheckSupplierWebhookSecretCommandTest.php`

**Modified files:**

- `app/app/Http/Controllers/Api/SupplierWebhookController.php` — Fix #ii (verifyIpAllowlist) + Fix #iii (timestamp validation)
- `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php` — TDD-тесты для Fix #ii + Fix #iii
- `db/schema.sql` — без изменений (Fix #iv не требует bump схемы; роль создаётся через `db/00_create_roles.sql`, не через `load_initial_schema.php`)
- `db/00_create_roles.sql` — Fix #iv: добавить `crm_supplier_worker` (BYPASSRLS)
- `app/app/Services/LeadRouter.php` — Fix #iv: обновить inline-warning (ссылка на `crm_supplier_worker`)
- `app/app/Console/Commands/ResetDeliveredTodayCommand.php` — Fix #iv: обновить inline-warning
- `docs/superpowers/plans/2026-05-10-supplier-plan26-cleanup.md` — статус-header после Task 5

**Memory updates (вне репо, в `~/.claude/projects/.../memory/`):**

- `feedback_environment.md` — новый quirk про deploy queue worker под `crm_supplier_worker`
- `project_supplier_integration.md` — Plan 2.6 closure секция
- `project_state.md` — header (HEAD origin/main, Pest count)

---

## Task 1: Fix #i — `supplier:check-webhook-secret` deploy validator (WARN #4)

**Files:**

- Create: `app/app/Console/Commands/CheckSupplierWebhookSecretCommand.php`
- Create: `app/tests/Feature/Console/CheckSupplierWebhookSecretCommandTest.php`

**Контекст:** Seed в `db/schema.sql:2582` — `supplier_webhook_secret = '__SET_ON_DEPLOY__'` (17 chars). `SupplierWebhookController::verifySecret` (line 96-98) явно блокирует placeholder OR len < 32 → 404. На production без deploy-time override — endpoint нерабочий silently. Нужен fail-fast deploy-gate.

- [ ] **Step 1: Failing test (4 кейса в одном файле)**

Создать `app/tests/Feature/Console/CheckSupplierWebhookSecretCommandTest.php`:

```php
<?php

declare(strict_types=1);

use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;

uses(DatabaseTransactions::class);

it('rejects placeholder seed `__SET_ON_DEPLOY__`', function (): void {
    DB::table('system_settings')
        ->updateOrInsert(
            ['key' => 'supplier_webhook_secret'],
            ['value' => '__SET_ON_DEPLOY__', 'value_type' => 'string', 'description' => 'test seed']
        );

    $exitCode = artisan('supplier:check-webhook-secret')->run();

    expect($exitCode)->toBe(1);
});

it('rejects too-short secret (< 32 chars)', function (): void {
    DB::table('system_settings')
        ->updateOrInsert(
            ['key' => 'supplier_webhook_secret'],
            ['value' => 'short-secret-only-20-chars', 'value_type' => 'string', 'description' => 'test']
        );

    $exitCode = artisan('supplier:check-webhook-secret')->run();

    expect($exitCode)->toBe(1);
});

it('rejects missing seed', function (): void {
    DB::table('system_settings')->where('key', 'supplier_webhook_secret')->delete();

    $exitCode = artisan('supplier:check-webhook-secret')->run();

    expect($exitCode)->toBe(1);
});

it('accepts valid secret (≥32 chars and not placeholder)', function (): void {
    DB::table('system_settings')
        ->updateOrInsert(
            ['key' => 'supplier_webhook_secret'],
            ['value' => str_repeat('a', 64), 'value_type' => 'string', 'description' => 'test seed']
        );

    $exitCode = artisan('supplier:check-webhook-secret')->run();

    expect($exitCode)->toBe(0);
});
```

NB: `artisan()` — Pest helper для PestPhp\Plugins\Laravel; alternative — `\Illuminate\Support\Facades\Artisan::call('supplier:check-webhook-secret')`. Проверить какой используется в проекте — посмотреть существующий `tests/Feature/Console/ResetDeliveredTodayCommandTest.php` и взять оттуда паттерн.

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

```powershell
./vendor/bin/pest tests/Feature/Console/CheckSupplierWebhookSecretCommandTest.php
```

Expected: 4 failed (Command not found / "There are no commands defined in the 'supplier' namespace").

- [ ] **Step 3: Implement `CheckSupplierWebhookSecretCommand`**

Создать `app/app/Console/Commands/CheckSupplierWebhookSecretCommand.php`:

```php
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;

/**
 * Deploy-time validator для supplier_webhook_secret.
 *
 * Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §5.1.
 * Plan 2.6 fix #i: closes CV.11 audit WARN #4 (placeholder seed на production
 * → endpoint silent 404 на ВСЕ запросы).
 *
 * Использование в deploy-script: `php artisan supplier:check-webhook-secret`
 * exit code 0 = OK, exit code 1 = блокирующая ошибка (deploy не должен продолжаться).
 *
 * Проверки:
 *   1. system_settings row существует с key='supplier_webhook_secret'.
 *   2. value !== '__SET_ON_DEPLOY__' (placeholder из schema seed).
 *   3. strlen(value) >= 32 chars (требование verifySecret в SupplierWebhookController).
 */
class CheckSupplierWebhookSecretCommand extends Command
{
    protected $signature = 'supplier:check-webhook-secret';

    protected $description = 'Deploy-time validator: проверка supplier_webhook_secret seed (Plan 2.6 fix #i)';

    public function handle(): int
    {
        $row = DB::table('system_settings')->where('key', 'supplier_webhook_secret')->first();

        if ($row === null) {
            $this->error('FAIL: system_settings row для key=supplier_webhook_secret не найдена. Schema seed повреждён или БД не мигрирована.');

            return self::FAILURE;
        }

        $value = (string) $row->value;

        if ($value === '__SET_ON_DEPLOY__') {
            $this->error('FAIL: supplier_webhook_secret = "__SET_ON_DEPLOY__" (placeholder из schema seed). Override через UPDATE system_settings перед deploy.');

            return self::FAILURE;
        }

        if (strlen($value) < 32) {
            $this->error('FAIL: supplier_webhook_secret слишком короткий (length='.strlen($value).', нужно ≥32 chars для совместимости с verifySecret в SupplierWebhookController).');

            return self::FAILURE;
        }

        $this->info('OK: supplier_webhook_secret valid (length='.strlen($value).' chars, не placeholder).');

        return self::SUCCESS;
    }
}
```

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

```powershell
./vendor/bin/pest tests/Feature/Console/CheckSupplierWebhookSecretCommandTest.php
```

Expected: 4 passed.

- [ ] **Step 5: Larastan + Pint**

```powershell
./vendor/bin/phpstan analyse --memory-limit=512M app/Console/Commands/CheckSupplierWebhookSecretCommand.php
./vendor/bin/pint --test app/Console/Commands/CheckSupplierWebhookSecretCommand.php
```

Expected: passed / passed.

- [ ] **Step 6: Commit (atomic)**

```bash
git add app/app/Console/Commands/CheckSupplierWebhookSecretCommand.php app/tests/Feature/Console/CheckSupplierWebhookSecretCommandTest.php
git commit -m "$(cat <<'EOF'
feat(commands): supplier:check-webhook-secret — deploy validator (Plan 2.6 #i)

Закрывает CV.11 audit WARN #4 (placeholder secret '__SET_ON_DEPLOY__' = silent
404 на production).

Console command для deploy-script: SELECT system_settings.supplier_webhook_secret
→ exit 1 если placeholder OR len < 32 OR row отсутствует. Иначе exit 0.

Использование: deploy-script вызывает `php artisan supplier:check-webhook-secret`
перед запуском приложения; non-zero exit прерывает deploy.

TDD: 4 теста (placeholder rejected / short rejected / missing rejected / valid accepted).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 2: Fix #ii — IP allowlist production fail-closed (WARN #5)

**Files:**

- Modify: `app/app/Http/Controllers/Api/SupplierWebhookController.php` (lines 109-115 в `verifyIpAllowlist`)
- Modify: `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php` (+ failing test)

**Контекст:** `verifyIpAllowlist` (line 113-114): `if ($list === []) return true;` — fail-open, любой IP пропускается. На dev OK (localhost development), на production — security gap. Inline-warning lines 34-39 признаёт. Fix: в production env пустой allowlist = блокировать.

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

Добавить в `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php` (в конец файла перед `?>` или последний `});`):

```php
it('blocks empty IP allowlist в production env (Plan 2.6 fix #ii)', function (): void {
    // Setup: valid secret + пустой IP allowlist (default seed).
    DB::table('system_settings')
        ->updateOrInsert(
            ['key' => 'supplier_webhook_secret'],
            ['value' => str_repeat('a', 64), 'value_type' => 'string', 'description' => 'test seed']
        );
    DB::table('system_settings')
        ->updateOrInsert(
            ['key' => 'supplier_ip_allowlist'],
            ['value' => '[]', 'value_type' => 'json', 'description' => 'test seed']
        );

    // Mock app environment как production.
    app()->detectEnvironment(fn () => 'production');

    $response = $this->postJson('/api/webhook/supplier/'.str_repeat('a', 64), [
        'vid' => 1,
        'project' => 'B1_test.ru',
        'phone' => '79991234567',
        'time' => now()->getTimestamp(),
    ]);

    $response->assertStatus(404);
});

it('allows empty IP allowlist в dev env (Plan 2.6 fix #ii — fail-open для dev)', function (): void {
    DB::table('system_settings')
        ->updateOrInsert(
            ['key' => 'supplier_webhook_secret'],
            ['value' => str_repeat('a', 64), 'value_type' => 'string', 'description' => 'test seed']
        );
    DB::table('system_settings')
        ->updateOrInsert(
            ['key' => 'supplier_ip_allowlist'],
            ['value' => '[]', 'value_type' => 'json', 'description' => 'test seed']
        );

    // Test env (default — testing) → fail-open сохраняется.

    $response = $this->postJson('/api/webhook/supplier/'.str_repeat('a', 64), [
        'vid' => 999999,
        'project' => 'B1_dev-test.ru',
        'phone' => '79991234567',
        'time' => now()->getTimestamp(),
    ]);

    // 202 = accepted (значит verifyIpAllowlist пропустил).
    $response->assertStatus(202);
});
```

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

```powershell
./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookTest.php --filter="blocks empty IP allowlist в production"
```

Expected: 1 failed (assertStatus 404, actual 202 — потому что fix ещё не применён, production env не блокирует).

- [ ] **Step 3: Implement fix в `verifyIpAllowlist`**

Modify `app/app/Http/Controllers/Api/SupplierWebhookController.php`:

```php
private function verifyIpAllowlist(?string $clientIp): bool
{
    if ($clientIp === null) {
        return false;
    }
    $row = DB::table('system_settings')->where('key', 'supplier_ip_allowlist')->first();
    if ($row === null) {
        return true;
    }
    $list = json_decode((string) $row->value, true) ?: [];
    if ($list === []) {
        // Plan 2.6 fix #ii: production env — пустой allowlist fail-closed (защита от
        // забытого override в schema seed); dev/testing — fail-open для localhost dev.
        // CV.11 audit WARN #5: inline-warning lines 34-39 признавал проблему,
        // теперь enforced.
        return ! app()->environment('production');
    }

    return IpUtils::checkIp($clientIp, $list);
}
```

Также обновить inline-warning header в файле (lines 34-39) — заменить «только secret защищает» на «production env enforce'ит non-empty allowlist через verifyIpAllowlist».

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

```powershell
./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookTest.php
```

Expected: все тесты passed (включая 2 новых + существующие 8 не сломались).

- [ ] **Step 5: Larastan + Pint**

```powershell
./vendor/bin/phpstan analyse --memory-limit=512M app/Http/Controllers/Api/SupplierWebhookController.php
./vendor/bin/pint --test app/Http/Controllers/Api/SupplierWebhookController.php
```

Expected: passed / passed.

- [ ] **Step 6: Commit (atomic)**

```bash
git add app/app/Http/Controllers/Api/SupplierWebhookController.php app/tests/Feature/Http/Webhook/SupplierWebhookTest.php
git commit -m "$(cat <<'EOF'
fix(http): IP allowlist fail-closed в production env (Plan 2.6 #ii)

Закрывает CV.11 audit WARN #5 (пустой supplier_ip_allowlist '[]' = fail-open
на production — любой IP пропускается).

Изменение: SupplierWebhookController::verifyIpAllowlist — если allowlist пустой,
возвращаем true только если env != production. На production пустой allowlist
блокирует (404). На dev/testing fail-open сохраняется (для localhost development).

TDD: +2 теста (production env empty → 404; testing env empty → 202).
Inline-warning header обновлён.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 3: Fix #iii — Timestamp partition guard ±24h (WARN minor #5)

**Files:**

- Modify: `app/app/Http/Controllers/Api/SupplierWebhookController.php` (lines 52-60 validation rules)
- Modify: `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php` (+ 2 failing tests)

**Контекст:** `Carbon::createFromTimestamp((int) $payload['time'])` (`RouteSupplierLeadJob.php:168`) использует raw `time` от поставщика. Партиции `deals` месячные (`deals_2026_05` от 2026-05-01). Timestamp за пределами текущего месячного окна → INSERT упадёт с `no partition of relation "deals" found for row`. Fix: контроллер отвергает webhooks с time за окном [now-24h, now+24h].

- [ ] **Step 1: Failing tests**

Добавить в `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php`:

```php
it('rejects timestamp older than 24h (Plan 2.6 fix #iii — partition guard)', function (): void {
    DB::table('system_settings')
        ->updateOrInsert(
            ['key' => 'supplier_webhook_secret'],
            ['value' => str_repeat('a', 64), 'value_type' => 'string', 'description' => 'test seed']
        );

    $response = $this->postJson('/api/webhook/supplier/'.str_repeat('a', 64), [
        'vid' => 100001,
        'project' => 'B1_old-time.ru',
        'phone' => '79991234567',
        'time' => now()->subDays(2)->getTimestamp(),
    ]);

    $response->assertStatus(422)->assertJsonValidationErrors('time');
});

it('rejects timestamp more than 24h in future (Plan 2.6 fix #iii — partition guard)', function (): void {
    DB::table('system_settings')
        ->updateOrInsert(
            ['key' => 'supplier_webhook_secret'],
            ['value' => str_repeat('a', 64), 'value_type' => 'string', 'description' => 'test seed']
        );

    $response = $this->postJson('/api/webhook/supplier/'.str_repeat('a', 64), [
        'vid' => 100002,
        'project' => 'B1_future-time.ru',
        'phone' => '79991234567',
        'time' => now()->addDays(2)->getTimestamp(),
    ]);

    $response->assertStatus(422)->assertJsonValidationErrors('time');
});

it('accepts timestamp within ±24h window (Plan 2.6 fix #iii — partition guard)', function (): void {
    DB::table('system_settings')
        ->updateOrInsert(
            ['key' => 'supplier_webhook_secret'],
            ['value' => str_repeat('a', 64), 'value_type' => 'string', 'description' => 'test seed']
        );

    $response = $this->postJson('/api/webhook/supplier/'.str_repeat('a', 64), [
        'vid' => 100003,
        'project' => 'B1_valid-time.ru',
        'phone' => '79991234567',
        'time' => now()->subHours(6)->getTimestamp(),
    ]);

    $response->assertStatus(202);
});
```

- [ ] **Step 2: Run tests to verify failure**

```powershell
./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookTest.php --filter="timestamp"
```

Expected: 2 failed (-2 days и +2 days сейчас принимаются как valid → assertStatus 422 fails).

- [ ] **Step 3: Implement validation в `receive`**

Modify `SupplierWebhookController::receive`:

```php
public function receive(Request $request, string $secret): JsonResponse
{
    if (! $this->verifySecret($secret)) {
        return response()->json(['message' => 'Not found.'], 404);
    }

    if (! $this->verifyIpAllowlist($request->ip())) {
        return response()->json(['message' => 'Not found.'], 404);
    }

    // Plan 2.6 fix #iii: timestamp partition guard. Партиции deals месячные
    // (deals_2026_MM); time за пределами текущего месяца → INSERT CRASH
    // "no partition of relation deals found for row". Окно ±24h защищает от
    // wildly out-of-range значений (старый/будущий дроп от поставщика).
    $minTime = now()->subDay()->getTimestamp();
    $maxTime = now()->addDay()->getTimestamp();

    $validated = $request->validate([
        'vid' => 'required|integer|min:1',
        'project' => ['required', 'string', 'max:255', 'regex:/^B[123]_.+$/'],
        'phone' => ['required', 'string', 'regex:/^7\d{10}$/'],
        'time' => ['required', 'integer', "min:{$minTime}", "max:{$maxTime}"],
        'tag' => 'nullable|string|max:255',
        'phones' => 'nullable|array',
        'phones.*' => 'string|regex:/^7\d{10}$/',
    ]);

    // ... (остальной код без изменений)
}
```

- [ ] **Step 4: Run tests**

```powershell
./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookTest.php
```

Expected: все passed (включая 3 новых).

- [ ] **Step 5: Larastan + Pint**

```powershell
./vendor/bin/phpstan analyse --memory-limit=512M app/Http/Controllers/Api/SupplierWebhookController.php
./vendor/bin/pint --test app/Http/Controllers/Api/SupplierWebhookController.php
```

Expected: passed / passed.

- [ ] **Step 6: Commit (atomic)**

```bash
git add app/app/Http/Controllers/Api/SupplierWebhookController.php app/tests/Feature/Http/Webhook/SupplierWebhookTest.php
git commit -m "$(cat <<'EOF'
fix(http): timestamp validation ±24h для partition guard (Plan 2.6 #iii)

Закрывает CV.11 audit WARN minor #5 (Carbon::createFromTimestamp(time) без
range guard → INSERT CRASH "no partition of relation deals found for row"
для timestamp вне текущего месячного окна).

Изменение: SupplierWebhookController::receive — добавлено min/max constraint
на 'time' = [now-24h, now+24h] unix-timestamp. Timestamp вне окна → 422
ValidationException.

±24h: покрывает retry-задержки поставщика (network-сбой) + clock-drift серверов;
шире окно (±48h+) = риск partition-промаха на стыке месяцев (нужен Plan 5
partition cron).

TDD: +3 теста (-2 days → 422; +2 days → 422; -6h → 202).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 4: Fix #iv — `crm_supplier_worker` BYPASSRLS-роль (WARN minor #2 + #3)

**Files:**

- Modify: `db/00_create_roles.sql` (новая роль)
- Modify: `app/app/Services/LeadRouter.php` (inline-warning lines 25-29)
- Modify: `app/app/Console/Commands/ResetDeliveredTodayCommand.php` (inline-warning lines 16-18)

**Контекст:** `LeadRouter::matchEligibleProjects` (sharing-flow) и `ResetDeliveredTodayCommand` (cron) делают cross-tenant SELECT/UPDATE без `SET LOCAL app.current_tenant_id`. На dev (`postgres` BYPASSRLS) работает; на prod (`crm_app_user` RLS-enforce) — отвергнутся policy `tenant_isolation`. Plan 2.6 brainstorming → вариант C (BYPASSRLS-роль для queue worker).

Архитектурное обоснование: queue worker — backend system process, обрабатывает webhooks для разных tenant'ов sharing-flow + global crons. По дизайну видит все tenant'ы. Privilege-boundary совпадает с архитектурной ролью. WHERE(tenant_id=…) фильтры в коде (например `RouteSupplierLeadJob::createDealCopyForProject` line 161-164) сохраняются как defense-in-depth.

**Без TDD-теста на роль** — integration test требует создания роли в test DB + смены connection (overhead не оправдан); делаем grep-smoke в Step 5.

- [ ] **Step 1: Add role definition в `db/00_create_roles.sql`**

Прочитать существующий `db/00_create_roles.sql` для понимания style:

```powershell
cat db/00_create_roles.sql
```

Добавить блок (в конец файла):

```sql
-- =============================================================================
-- crm_supplier_worker — BYPASSRLS-роль для backend queue worker (Plan 2.6 fix #iv)
-- =============================================================================
-- Закрывает CV.11 audit WARN minor #2 + #3 (LeadRouter и ResetDeliveredTodayCommand
-- под crm_app_user → RLS-policy tenant_isolation отвергает cross-tenant SELECT/UPDATE).
--
-- Privilege-boundary by design:
--   • Queue worker (php artisan queue:work) = backend system process для cross-tenant
--     операций: sharing-webhook routing (RouteSupplierLeadJob), global crons
--     (projects:reset-delivered-today, supplier:check-webhook-secret).
--   • Web worker = end-user-facing, остаётся под crm_app_user (RLS-enforce).
-- WHERE(tenant_id=) фильтры в коде сохраняются как defense-in-depth даже под
-- BYPASSRLS-ролью.
--
-- Deploy:
--   • Создать роль через этот файл при первом deploy (DBA / migrate:fresh).
--   • Установить пароль через secrets manager (Yandex KMS / Vault).
--   • Queue worker .env (отдельный от web .env): DB_USERNAME=crm_supplier_worker.
--
-- Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §6.
-- Brainstorm decision (Plan 2.6, 10.05.2026 поздняя ночь): вариант C из 3 опций
-- (A=elevated DB-connection / B=RLS WITH-CHECK exception / C=BYPASSRLS-роль).
-- =============================================================================
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
        -- Пароль ОБЯЗАТЕЛЬНО override через secrets manager перед production deploy.
        EXECUTE format('CREATE ROLE crm_supplier_worker WITH LOGIN BYPASSRLS PASSWORD %L', '__SET_VIA_SECRETS_MANAGER__');
    END IF;
END $$;

GRANT USAGE ON SCHEMA public TO crm_supplier_worker;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO crm_supplier_worker;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO crm_supplier_worker;

-- DEFAULT PRIVILEGES для будущих таблиц / sequences:
ALTER DEFAULT PRIVILEGES IN SCHEMA public
    GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO crm_supplier_worker;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
    GRANT USAGE, SELECT ON SEQUENCES TO crm_supplier_worker;
```

- [ ] **Step 2: Update inline-warning в `LeadRouter.php`**

Modify lines 25-29 в `app/app/Services/LeadRouter.php`:

```php
 * RLS-quirk: запрос работает поверх N tenant'ов одновременно (sharing-model).
 * Не использует SET LOCAL app.current_tenant_id (в sharing-flow tenant ещё не определён —
 * запрос подбирает кандидатов из всех tenant'ов параллельно). На production queue worker
 * запускается под ролью crm_supplier_worker (BYPASSRLS) — Plan 2.6 fix #iv. На dev
 * подключение под postgres (BYPASSRLS implicit). См. db/00_create_roles.sql.
```

- [ ] **Step 3: Update inline-warning в `ResetDeliveredTodayCommand.php`**

Modify lines 14-18 в `app/app/Console/Commands/ResetDeliveredTodayCommand.php`:

```php
/**
 * Сброс projects.delivered_today=0 для всех tenant'ов.
 *
 * Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §6.1.
 * Расписание: каждый день в 00:00 МСК (timezone Europe/Moscow).
 *
 * NB: tenant-scoped запрос без RLS — UPDATE сразу на все tenant'ы. На production
 * queue worker (через Scheduler) запускается под ролью crm_supplier_worker
 * (BYPASSRLS) — Plan 2.6 fix #iv. На dev подключение под postgres (BYPASSRLS
 * implicit). См. db/00_create_roles.sql.
 */
```

- [ ] **Step 4: Smoke-grep verify (вместо integration-теста)**

```powershell
grep -c "crm_supplier_worker" db/00_create_roles.sql
grep -c "BYPASSRLS" db/00_create_roles.sql
grep -c "crm_supplier_worker" app/app/Services/LeadRouter.php
grep -c "crm_supplier_worker" app/app/Console/Commands/ResetDeliveredTodayCommand.php
```

Expected: каждый grep ≥1 (роль определена + cross-refs из кода работают).

- [ ] **Step 5: Run full Pest для regression check**

```powershell
./vendor/bin/pest --parallel
```

Expected: все тесты passed (Plan 2.6 fix #i + #ii + #iii добавили ~7-8 новых тестов; baseline после Plan 2.5 — 549/547, ожидается ~556-557).

- [ ] **Step 6: Larastan + Pint**

```powershell
./vendor/bin/phpstan analyse --memory-limit=512M
./vendor/bin/pint --test
```

Expected: passed / passed.

- [ ] **Step 7: Commit (atomic)**

```bash
git add db/00_create_roles.sql app/app/Services/LeadRouter.php app/app/Console/Commands/ResetDeliveredTodayCommand.php
git commit -m "$(cat <<'EOF'
feat(db): crm_supplier_worker BYPASSRLS-роль для queue worker (Plan 2.6 #iv)

Закрывает CV.11 audit WARN minor #2 + #3 (LeadRouter + ResetDeliveredTodayCommand
под crm_app_user → RLS-policy tenant_isolation отвергает cross-tenant SELECT/UPDATE).

Архитектурное решение (Plan 2.6 brainstorm 10.05.2026 поздняя ночь, вариант C из 3
опций): новая PG-роль crm_supplier_worker с BYPASSRLS — privilege-boundary by design.
Queue worker = backend system process для cross-tenant операций (sharing-webhook
routing, global crons); web worker остаётся под crm_app_user (RLS-enforce).

WHERE(tenant_id=) фильтры в коде сохраняются как defense-in-depth.

Deploy:
  • Роль создаётся через db/00_create_roles.sql при первом deploy.
  • Пароль override через secrets manager (Yandex KMS).
  • Queue worker .env: DB_USERNAME=crm_supplier_worker (отдельно от web .env).

Inline-warnings обновлены в LeadRouter.php + ResetDeliveredTodayCommand.php.

Без TDD-теста на роль (integration-тест требует CREATE ROLE в test DB +
смены connection — overhead не оправдан); smoke-grep проверяет файлы.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 5: Closure — Memory updates + Plan 2.6 status header + push

**Files:**

- Modify: `docs/superpowers/plans/2026-05-10-supplier-plan26-cleanup.md` (status header)
- Modify (memory, вне репо): `feedback_environment.md`, `project_supplier_integration.md`, `project_state.md`

- [ ] **Step 1: Re-run full verification**

```powershell
./vendor/bin/pest --parallel
./vendor/bin/phpstan analyse --memory-limit=512M
./vendor/bin/pint --test
```

Expected: всё green; Pest count = 549 (baseline) + 4 (Task 1 idempotency tests) + 2 (Task 2 IP allowlist tests) + 3 (Task 3 timestamp tests) = ~558 / ~556 passed.

- [ ] **Step 2: Update memory `feedback_environment.md`**

Добавить новый quirk-блок:

```markdown
- **Plan 2.6 fix #iv (10.05.2026 поздняя ночь): crm_supplier_worker BYPASSRLS-роль для queue worker.** Backend queue worker (php artisan queue:work) на production должен подключаться к БД под ролью `crm_supplier_worker` (BYPASSRLS), не под `crm_app_user` (RLS-enforce). Причина: sharing-webhook routing + global crons работают cross-tenant. См. `db/00_create_roles.sql`. Web worker остаётся под `crm_app_user`. Раздельные `.env` для web и queue.
```

- [ ] **Step 3: Update memory `project_supplier_integration.md`**

Добавить секцию «Plan 2.6 cleanup (10.05.2026 поздняя ночь финал) — closure CV.11 operational + RLS остатков» с детализацией:

- 4 fix'а закрыты (4 atomic commits)
- Pest count после Plan 2.6
- Что осталось в Plan 3 backlog (BLOCKER #6 + 5 NIT + некоторые WARN minor #1, #4, #6, #7, #8)

Также обновить таблицу декомпозиции Plans 1-5: добавить строку «2.6 hotfix #i+ii+iii+iv ✅ DONE» по аналогии с Plan 2.5.

- [ ] **Step 4: Update memory `project_state.md`**

Header description: HEAD origin/main = новый SHA (после push); Pest count = реальное число.

- [ ] **Step 5: Update Plan 2.6 file — status header**

Modify `docs/superpowers/plans/2026-05-10-supplier-plan26-cleanup.md` — добавить blockquote после h1:

```markdown
> **✅ Plan completed (10.05.2026 поздняя ночь финал).** Все 4 fix'а закрыты атомарными commit'ами. Pest <N>/<M> passed (+9 тестов от Plan 2.5 baseline 549/547). Larastan + Pint clean. Memory updated. CV.11 audit Plan 3 backlog: BLOCKER #6 (RLS на failed_webhook_jobs INSERT NULL tenant) + 5 NIT + 5 minor WARN.
```

- [ ] **Step 6: Commit Plan 2.6 status header**

```bash
git add docs/superpowers/plans/2026-05-10-supplier-plan26-cleanup.md
git commit -m "$(cat <<'EOF'
docs(plans): close Plan 2.6 — ✅ status header

Все 4 fix'а закрыты атомарными commits:
  • Fix #i (placeholder secret deploy validator)
  • Fix #ii (IP allowlist production fail-closed)
  • Fix #iii (timestamp partition guard ±24h)
  • Fix #iv (crm_supplier_worker BYPASSRLS-роль)

Pest <N>/<M> passed (+9 тестов от Plan 2.5 baseline 549/547).
Larastan + Pint clean.

Plan 3 backlog: BLOCKER #6 + 5 NIT + 5 minor WARN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

- [ ] **Step 7: Push origin main**

```bash
git push origin main
```

Expected: lefthook pre-push (gitleaks-full-history + lychee) PASS, push successful. После Plan 2.6 на origin/main будет 5 новых commits (Tasks 1-5).

---

## Self-review

**Spec coverage:**

- ✅ WARN #4 (placeholder secret) — Task 1.
- ✅ WARN #5 (empty IP allowlist) — Task 2.
- ✅ WARN minor #5 (Carbon partition crash) — Task 3.
- ✅ WARN minor #2 + #3 (LeadRouter + ResetCmd RLS под crm_app_user) — Task 4.
- ✅ Memory closure — Task 5.

**Placeholder scan:** нет TODO/TBD/«implement later». Все шаги имеют конкретный код / команды.

**Type consistency:** Console signature `supplier:check-webhook-secret` (Task 1) — не дублируется. `verifyIpAllowlist` (Task 2) и `receive` validation (Task 3) — оба в `SupplierWebhookController`, без коллизий имен. `crm_supplier_worker` имя роли — единое во всех Tasks (4 и 5).

**Что НЕ покрыто (вынесено в Plan 3 / NOTES):**

- BLOCKER #6 (RLS на failed_webhook_jobs INSERT NULL tenant) — архитектурное решение (отдельная SaaS-таблица или INSERT-policy WITH CHECK true), Plan 3 первая задача.
- WARN minor #1 (SET LOCAL quotes стилистика) — defer.
- WARN minor #4 (COALESCE NOT NULL guard) — defer.
- WARN minor #6 (PhonePrefix re-validation в job) — defer.
- WARN minor #7 (parsePlatform DRY) — defer.
- WARN minor #8 (DuplicateDetector тай-брейкер) — defer.
- 5 NIT — defer.

---

## Execution Handoff

**Plan saved:** `docs/superpowers/plans/2026-05-10-supplier-plan26-cleanup.md`.

**Two execution options:**

1. **Subagent-Driven (recommended)** — fresh subagent per task + 2-stage review между.
2. **Inline Execution** — гонит таски в текущей сессии через `superpowers:executing-plans`.

**Which approach?**
