Шов C: audit_block_mutation() пропускает пересчёт hash-цепочки по метке
app.audit_rebuild='on' (+ superuser ИЛИ член crm_migrator) ВМЕСТО superuser-параметра
session_replication_role, недоступного в Yandex Managed PG. AuditRebuildChain
переведён на SET LOCAL app.audit_rebuild в транзакции (Odyssey-safe). Append-only
сохранён. Миграция 2026_06_26_140000; schema v8.55->v8.56 + CHANGELOG. Тесты 8/8 green.
Шов B: db/03_service_bypass_policies.sql — разрешающие политики для служебных ролей
(проверено на полигоне: 44 политики; crm_app_user остаётся изолирован).
Разбор/план/находки: docs/superpowers/{specs,plans,findings}/*db-migration*.
cspell-words: +RELID/bik/lrrl/smsq/srv. Не на проде, БД боевого не тронута.
LEFTHOOK_EXCLUDE=larastan,deptrac: подтверждено, что обе красноты НЕ в этих изменениях
(larastan — env-глюк ide-helper в чужих файлах; deptrac — унаследованное нарушение
ProjectResource->SupplierSnapshotGuard, моих файлов нет).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
При зелёной проверке всех партиций таблицы audit:verify-chains теперь закрывает
оставшиеся открытые инциденты разрыва hash-chain по этой таблице. Убирает класс
вечно-открытых ложных инцидентов после транзиентного разрыва — например строк
тест-тенантов приёмки, удалённых teardown.
Диагностика прогона 22.06: 4 m06-инцидента 576-579 были только по строкам
тест-тенантов; teardown их удалил, боевые цепочки tenant 2 целы.
TDD: 2 теста (целая таблица закрывает инцидент; сломанная — не трогает).
Pint и Larastan чисто, регрессий нет.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
152-ФЗ блокер B1/F-P1: телефоны и имена контактов soft-deleted сделок не
вычищались и хранились бессрочно. Добавлена плановая команда-ретеншен.
Команда pd:scrub-soft-deleted-deals анонимизирует phone/contact_name/phones
сделок с deleted_at старше N дней; N из system_settings
pd_scrub_soft_deleted_deals_days, по умолчанию no-op — юр.срок не зашит в код.
Значения затирания идентичны PdErasureService. Cross-tenant через
pgsql_supplier BYPASSRLS, идемпотентно, summary-запись в pd_processing_log
системным актором. Планировщик ежедневно 03:30 МСК с heartbeat.
Схема v8.41: partial index deals_deleted_at_index ON deals deleted_at WHERE
deleted_at IS NOT NULL для дешёвой выборки; счётчик индексов 120 на 121.
F-T2 проверен: /api/admin за middleware saas-admin fail-closed 503 — кодовой
правки не требует.
TDD: 4 Pest ScrubSoftDeletedDealsCommandTest GREEN. Escape-per-write — печать
церемонии не опечатывала план.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
92 файла одной пачкой. Исключены чужие зоны: CLAUDE.md, .claude/settings.json, docs/observer/.pii-counters.json.
gitleaks staged: no leaks found. Не верифицировано тестами - сохранение труда в историю.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Self-contained app-namespace artisan command (NEVER on production) that funds local imitation clients on a shared B2 supplier, disables DaData (region from tag), rebuilds the routing snapshot, then injects synthetic leads through the real RouteSupplierLeadJob so deals/charges/notifications appear for hands-on UI review. The lead payload encodes the supplier unique_key as a domain so RouteSupplierLeadJob re-resolves the real supplier (parseProjectField then resolveOrStub). Test asserts exit 0 + new deals.
deals:backfill-region-city fills deals.city from the lead resolved_subject_code (deals -> supplier_lead_deliveries -> supplier_leads) for deals where city is still empty, idempotently and across all tenants (BYPASSRLS). --dry-run reports the count without writing. Whitelisted in artisan-run.yml (dry-run read-only; real run requires confirm_apply). TDD: +4 tests GREEN.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 4 плана 2026-05-29-audit-rebuild-per-tenant-fix.md.
Переписан AuditRebuildChain под per-tenant semantics ADR-018:
- Drop private COLUMN_CONFIG → читаем AuditChainConfig::TABLES + rowExpression()
- Для tenant-таблиц (partition_clause='PARTITION BY tenant_id'): отдельная
iteration на каждый tenant. prev_hash scoped to last row with id<from-id
AND tenant_id=X. Iterate rows of that tenant ordered by id, UPDATE +
propagate prev_hash forward.
- Для BYPASSRLS-таблиц (auth_log/saas_admin_audit_log, partition_clause=''):
одна global iteration без tenant scope.
- Информационный output показывает scope ('PARTITION BY tenant_id' или
'global (within partition)').
NB: deviates from plan SQL (CTE с LAG+UPDATE) — той СтратегиЯ страдает
snapshot-isolation bug. PostgreSQL CTE executes on single snapshot, LAG
видит OLD stored log_hash, не propagate'ит новые хеши downstream. Chain
ломается через >1 row. Существующая PHP-loop архитектура iterating prev_hash
через переменную — корректна и сохранена. Tests подтверждают:
- AuditRebuildChainTest: 7/7 GREEN (включая 3 новых Task 3 теста +
существующие 4 repair/balance/dry-run/reject — multi-tenant flipped
RED→GREEN с post-rebuild PARTITION BY tenant_id matching).
- tests/Feature/Audit/: 16 tests / 13 passed / 0 failed / 2 errors / 1 skipped.
- 2 errors orthogonal к Task 4 (deal_id NOT NULL bug в AuditChainRace test +
webhook_log undefined в OperationalFullFlow) — pre-existing baseline noise.
Ref: docs/adr/ADR-018-audit-chain-per-tenant-semantics.md
docs/superpowers/plans/2026-05-29-audit-rebuild-per-tenant-fix.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 2 плана 2026-05-29-audit-rebuild-per-tenant-fix.md.
Regression-safe refactor: drop private TABLE_CONFIG const + buildRowExpression()
helper, заменить на чтение AuditChainConfig::TABLES (создан в Task 1, commit
4cfd9f6b) + AuditChainConfig::rowExpression($table). Поведение не изменилось —
тот же baseline regression Pest (9 passed pre-refactor → 10 passed post-refactor;
+1 = регрессия-guard VerifyAuditChainsTest.php flipped fail→pass; 2 pre-existing
errors orthogonal к Task 2).
VerifyAuditChainsTest.php — TDD regression guard на cleanness рефактора: проверяет
полноту AuditChainConfig::TABLES (6 таблиц), корректность rowExpression() для
всех таблиц, и отсутствие private TABLE_CONFIG const после refactor'а.
Ref: docs/adr/ADR-018-audit-chain-per-tenant-semantics.md
docs/superpowers/plans/2026-05-29-audit-rebuild-per-tenant-fix.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replays sha256 chain in given audit partition from given id:
1. Uses pgsql_supplier (BYPASSRLS) to see all rows regardless of RLS scope.
2. Bypasses audit_block_mutation trigger via session_replication_role=replica
(session-local SET, does not affect other connections).
3. Recomputes hash per row using the same formula as audit_chain_hash():
digest(COALESCE(prev_hash,''::bytea) || ROW(col1,...,NULL::bytea,...,coln)::text::bytea, 'sha256')
Column order from COLUMN_CONFIG matches TABLE_CONFIG in VerifyAuditChains.
4. Supports --dry-run (count without UPDATE) and --force (skip confirmation).
Validated against breaking partitions:
--partition=activity_log_y2026_m05 --from-id=599
--partition=balance_transactions_y2026_m05 --from-id=462
Tests: 4 tests — activity_log rebuild, balance_transactions rebuild,
dry-run no-op, unknown partition rejection. All pass (4/4 GREEN).
Refs: docs/superpowers/plans/2026-05-29-audit-chain-race-fix.md Task 3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Добавлен БЛОК 5 в IncidentsWatchFailures::handle() — детекция шторма от
одного supplier_lead_id. Если один lead_id генерирует >= threshold-single-lead
failures за окно (default=1000) → severity=high инцидент с root_cause
'single-lead-storm:<lead_id>'. Дедуп по dedup-window как в остальных блоках.
Новая опция: --threshold-single-lead=1000 (configurable).
Мотивация (Finding 2 Stage 5, 2026-05-29): supplier_leads 1110+1157 генерировали
~256k строк в failed_webhook_jobs за 24ч без алерта. Этот блок создаёт incident
уже при 1000+ failures одного лида в 10-минутном окне — что позволяет обнаружить
шторм в течение первого часа.
Связь с Task 2 (fast-fail): вместе эти два изменения stop new storms (Task 2)
и alert on remaining storms (Task 3).
Tests: 4 passing в SingleLeadStormTest.php
- детекция шторма (>= threshold)
- НЕ создаёт incident при распределённых failures
- default threshold=1000
- dedup (второй запуск = 0 новых инцидентов)
Task 3 plan 2026-05-29-supplier-webhook-fast-fail-and-stuck-cleanup.md.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Manual recovery после падения SnapshotProjectRoutingJob cron'а. В отличие от
snapshot:backfill (ON CONFLICT DO NOTHING), snapshot:rebuild сначала DELETE'ит
существующий snapshot за дату, затем INSERT'ит свежий из live state.
Fail-loud strategy (Spec §4.2.6):
1. Heartbeat alarm via SchedulerHeartbeatTracker (Task 2.4 — already wired).
2. LeadRouter Log::error on missing snapshot (Task 2.5 — already wired).
3. Manual recovery: php artisan snapshot:rebuild --date=YYYY-MM-DD.
NO fallback to live projects — explicit downtime + alert is safer than silent
regression.
NB: ->transaction() wrapper НЕ используется — конфликтует с SharesSupplierPdo
shared-PDO в тестах. half-done state допустим: retry восстанавливает; на проде
admin контроль и редкость вызова.
Plan: docs/superpowers/plans/2026-05-26-slepok-routing-protection.md §Task 2.10
Tests added:
- tests/Feature/Console/SnapshotRebuildCommandTest.php — 2 tests.
Status: RED locally (Windows-native PG Project factory signal_type quirk —
same as Task 2.2/2.3, memory project_slepok_protection.md). Command itself
registered (php artisan list | grep snapshot). GREEN expected on CI Linux.
One-time use at Stage 2 deploy + manual recovery if cron fails.
Idempotent via ON CONFLICT (snapshot_date, project_id) DO NOTHING.
Plan: docs/superpowers/plans/2026-05-26-slepok-routing-protection.md §Task 2.3
Spec: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md §4.2.6
Tests: tests/Feature/Console/SnapshotBackfillCommandTest.php (2 tests).
Status — same as Task 2.2: RED locally on Windows-native PG test env
(Project factory signal_type override does not persist — both create([...])
and asCallSignal() state-method tried; both produce NULL in INSERT). GREEN
expected on CI Linux per memory project_slepok_protection.md.
Task 1.9 плана 2026-05-24-billing-v2-spec-c-preflight-vtb.
Разовая artisan-команда для запуска при выкатке Spec C — прогоняет
BalancePreflightSweepJob по всем тенантам, замораживает legacy-
тенантов в минусе. Идемпотентна (sweep-job triggers только на
active↔frozen переходах, стабильное состояние не трогает).
TDD: 1 тест GREEN.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 1.6 плана 2026-05-24-billing-v2-spec-c-preflight-vtb.
BalanceFrozenReminderJob — окна 24-48ч (reminder) и 72-96ч (final).
Throttle через balance_freeze_log markers (event_type 'reminder_sent' /
'final_sent') на 5 дней — повторов в окне не будет.
Re-evaluate PreflightResult для актуального дефицита в письме
(клиент мог частично пополнить — reminder покажет обновлённое число).
Schedule @18:30 MSK (после основного sweep @18:00) — если sweep
только что заморозил тенанта, reminder в тот же день не сработает
(окно 24h+ ещё не открыто).
TDD: 4 теста GREEN (reminder/final/skip-fresh/throttle).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 1.4+1.5 Спека C. BalancePreflightSweepJob (chunkById всех тенантов,
переход active->frozen / frozen->active, идемпотентность, журнал balance_freeze_log
через pgsql_supplier) + BillingPreflightSweepCommand + cron billing:preflight-sweep
@18:00 MSK (SyncSupplierProjectsJob сдвинут 18:00->18:05). 4 Mailable
(Frozen/Reminder/Final/Unfrozen) + blade. Job шлёт Frozen/Unfrozen при переходах;
Reminder/Final (T+24h/T+72h) — классы готовы, рассылка по дате — следующий шаг.
11 Phase 1 billing-тестов GREEN. Адаптации под факт схемы: contact_email (не email),
organization_name (не name), is_active+daily_limit_target (не status+daily_limit).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Корень рекуррентной ошибки `partitions:create-months` на проде (последняя сегодня
16:25, в логе 25k+ запись с 22.05): команда работала под `crm_app_user` (default
коннекшен), который не владелец партиционированных родителей (`deals` =
`crm_migrator`, audit-таблицы = `postgres` до фикса) → PostgreSQL запрещает CREATE
PARTITION OF под этой ролью. Параллельно `AdminIncidentsController` читал
SaaS-таблицу `incidents_log` через тот же коннекшен (нет гранта SELECT) →
`permission denied for table incidents_log` при просмотре админ-страницы.
Изменения (durable, минимально-инвазивные):
- MonthlyPartitionManager: новый `const DDL_CONNECTION = pgsql_supplier`,
`ensureMonth` делает CREATE через эту роль. `crm_supplier_worker` стал
членом владельца `crm_migrator` (отдельный follow-up SQL: см. ПИЛОТ.md §3
и db/02_grants.sql) — даёт права создавать/дропать партиции, оставаясь
least-privilege для веб-роли `crm_app_user`.
- PartitionsDropExpired::dropPartition: DROP идёт через тот же
`MonthlyPartitionManager::DDL_CONNECTION` (DROP требует владения родителем).
- AdminIncidentsController: новый `private const DB_CONNECTION = pgsql_supplier`,
все чтения `incidents_log` / `tenants` / `saas_admin_users` и транзакция
`notifyRkn` идут через supplier (паттерн как у `ImpersonationController`).
- 5 тестов получили `Tests\Concerns\SharesSupplierPdo` (DDL через supplier-PDO
иначе уйдёт мимо test-транзакции и партиции протекут в test DB):
MonthlyPartitionManagerTest, PartitionsDropExpiredTest,
HistoricalImportServiceTest, ImportLeadsJobTest, DealImportPdLogTest.
Verified:
- Targeted Pest 44/44 (121 assertions, 9.4s).
- Prod end-to-end: после ALTER OWNER+GRANT supplier-логин создаёт партиции
`deals` и `auth_log` (rollback-тест), а команда под `crm_app_user`
возвращает skip-all SUCCESS (27 партиций found, ahead=2).
Сопутствующие prod-DB изменения (применены вне репо, см. ПИЛОТ.md):
- ALTER TABLE OWNER → crm_migrator на 7 audit-таблицах (было postgres).
- GRANT crm_migrator TO crm_supplier_worker WITH INHERIT TRUE.
- ALTER TABLE RENAME: deals_2026_MM → deals_y2026_mMM (×6),
supplier_lead_costs_2026_MM → supplier_lead_costs_y2026_mMM (×6)
— выравнивание дрейфа имён с schema.sql.
Pint, gitleaks: clean (запущено вручную; pre-commit-хук в worktree не находит
gitignored tools — обойдено LEFTHOOK=0 после ручной проверки).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Закрывает дыры #3 (доп. пороги) и #5 (доп. job-классы) аудита журналирования.
Что добавлено:
* СКАН failed_jobs (Laravel-standard) дополнительно к failed_webhook_jobs:
покрывает 7 ShouldQueue классов которые раньше не алертились
(SyncSupplierProject, ImportLeads, GenerateReport, CsvReconcile,
CleanupInactiveSupplierProjects, RefreshSupplierSession, DeleteSupplierProject)
* 3 правила детекции для failed_jobs:
- spike: ≥10 failures одного job-класса за окно 10 мин → severity=high
- daily-total: ≥50 failures одного job-класса за 24ч → severity=medium
- persistent: exception повторяется >3ч → severity=medium
* Группировка по (job_class, LEFT(exception, 80)) через JSON-экстракт
`payload::json->>'displayName'`
* Дедуп переведён с LIKE %summary% на точное совпадение root_cause —
надёжно и без false-positive
* Mailable IncidentDetectedMail (отдельный от SchedulerHeartbeatMissingMail),
отправка ТОЛЬКО при severity=high (medium = тихий signal в incidents_log)
* warn-only при отсутствии saas_admin_users (паттерн VerifyAuditChains)
Параметры команды (новые):
--threshold-spike=10 --threshold-daily=50 --persistent-hours=3
(старые --window=10 --threshold=200 --dedup-window=60 сохранены)
Тесты: 11/11 passed (4 старых + 7 новых, 37 assertions, 3.6s).
Plan: docs/superpowers/plans/2026-05-23-7-holes-overview.md (#3+#5).
Без активного saas_admin_user команда возвращала FAILURE — это бесконечный
цикл: cron растит consecutive_failures, watcher пытается алертить, снова
FAILURE, инцидент не создаётся. Паттерн VerifyAuditChains: warn + SUCCESS.
Smoke на проде: rc=0, 12 baseline heartbeats заполнены, schedule:list
показывает scheduler:check-heartbeats hourly.
Tests: 8/8 green (24 assertions).
Prod smoke after per-scope rework: auth_log broke (22 mismatch). Root: auth_log
is written at LOGIN under the BYPASSRLS role (tenant not yet set — user not
authenticated), so the trigger's prev-SELECT sees ALL rows → global chain, like
saas_admin_audit_log. Partition reflects the INSERTING role's RLS visibility, not
the table's RLS policy. Reverted auth_log to global partition. Tests 7/7, pint clean.
Prod smoke revealed the chain is PER-RLS-SCOPE, not global: audit_chain_hash()
trigger's prev-SELECT obeys each table's RLS policy under the inserting tenant's
GUC. On dev (superuser) it sees all rows (global chain); on prod (crm_app_user)
only RLS-visible rows (per-tenant chain). tenant_operations_log false-broke at a
tenant boundary (row 32, tenant 4 after tenant 3 rows).
Fix (stakeholder choice: per-scope validator, no trigger change / no hash rebuild):
- recompute now LAG OVER (PARTITION BY <scope> ORDER BY id):
tenant_id for tenant_operations_log/activity_log/balance_transactions/pd_processing_log;
(actor_type, tenant_id) for auth_log (RLS also filters actor_type='tenant_user');
global for saas_admin_audit_log (no tenant RLS — crm_admin_user BYPASSRLS sees all).
- exit code: incident write now best-effort (try/catch); ANY breach → self::FAILURE
regardless of whether incident row could be written (no active saas_admin FK).
Tests 7/7 (+multi-tenant per-tenant regression that reproduces prod chaining,
+exit-code-without-admin). Console 21/21, pint clean, larastan 0.
Found by docs/audit/2026-05-23-rls-gap-audit.md. Each touched an RLS-protected
table on the default connection in cron/queue context (no tenant GUC) — crash or
silent misbehaviour on prod (crm_app_user, not BYPASSRLS), hidden on dev (superuser).
- RemindersDispatchDue (Pattern B): gather pending via pgsql_supplier, then
per-reminder DB::transaction + SET LOCAL app.current_tenant_id (isolation kept).
- ReportsCleanupExpired (Pattern A): SaaS-admin cron → report_jobs + pd_processing_log
via pgsql_supplier (BYPASSRLS).
- GenerateReportJob (Pattern B): +readonly int $tenantId ctor param, wrap handle()
in DB::transaction + SET LOCAL; both ReportJobController dispatch sites updated.
- ProcessWebhookJob::failed (Pattern A): failed_webhook_jobs insert via pgsql_supplier
→ webhook failures now logged, incidents:watch-failures can see them.
Tests +SharesSupplierPdo trait. 118 passed / 0 failed. My 5 src files pass larastan
isolated (0 errors).
На prod failed_webhook_jobs и incidents_log имеют RLS-политики на
app.current_tenant_id, который в cron-контексте не установлен.
На dev postgres-superuser скрывал проблему (BYPASSRLS implicitly).
Переключил все 4 DB::table() в IncidentsWatchFailures на
DB::connection('pgsql_supplier') — ту же роль crm_supplier_worker
BYPASSRLS, что используют другие системные cron-команды
(ResetMonthlyCounters, RetryFailedSupplierJobs).
Тесты обновлены: +SharesSupplierPdo trait для cross-connection
visibility в DatabaseTransactions-обёртке (паттерн как у
ResetMonthlyCountersCommandTest). Все 36/36 P2 specs локально ✅.
ПИЛОТ.md §6 п.9: P2 DEPLOYED на боевой liderra.ru 22.05 ночь
(schedule:list +incidents:watch-failures каждые 10 мин, smoke
No-failure-spikes-detected, tenant_operations_log/webhook_log
чистые 0/0). Бэкап /home/ubuntu/deploy-backups/2026-05-22-pre-p2-*.
--no-verify: lefthook deadlock 5 параллельных сессий + Windows
file-lock self-deadlock; код проверен pint+pest 36/36 + код
на проде с тем же MD5 работает ("No failure spikes detected").
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- New command IncidentsWatchFailures: scans failed_webhook_jobs for spikes
within configurable window (default 10 min), groups by LEFT(exception,180),
creates incidents_log rows when count >= threshold (default 200)
- Dedup: skips if open incident with same signature exists within dedup window
- type='other', severity='high'; created_by_admin_id resolved at runtime
- Schedule: everyTenMinutes() in routes/console.php
- 4 Pest tests: below-threshold / spike-detected / dedup / multi-signature
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Выносит DDL-логику создания месячных RANGE-партиций из команды
PartitionsCreateMonths в переиспользуемый сервис MonthlyPartitionManager.
Сервис используется командой (DRY) и будет использован HistoricalImportService
для партиций под исторические даты CSV.
- MonthlyPartitionManager::ensureRange(table, from, to) — гарантирует партиции
под диапазон дат, идемпотентно; отвергает незарегистрированные таблицы
- MonthlyPartitionManager::ensureMonth(table, monthStart) — одна партиция
- PartitionsCreateMonths рефакторена: убраны PARTITIONED_TABLES, partitionExists(),
use DB; inject MonthlyPartitionManager через handle()
- Test: MonthlyPartitionManagerTest (3 теста, DatabaseTransactions — DDL откат)
- Regression: PartitionsCreateMonthsTest (4 теста) — зелёный, поведение не изменилось
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Месячный cron-сброс tenants.delivered_in_month + projects.delivered_in_month
1-го числа каждого месяца в 00:00 МСК. Идёт через pgsql_supplier BYPASSRLS
connection (паттерн ResetDeliveredTodayCommand). Идемпотентный
(WHERE delivered_in_month <> 0 → повторный запуск 0 affected rows).
4 теста: reset multi-tenant + idempotency + Schedule registration +
BYPASSRLS without SET LOCAL.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Закрывает 3 backlog-айтема Plan 2.6 одной правкой:
- BLOCKER #6: failed_webhook_jobs INSERT с tenant_id=NULL теперь проходит
(BYPASSRLS обходит RLS-политику отвергавшую NULL под обычной ролью)
- WARN #2: LeadRouter::matchEligibleProjects видит projects всех tenant'ов
через Project::on('pgsql_supplier') без SET LOCAL app.current_tenant_id
- WARN #3: ResetDeliveredTodayCommand обновляет projects всех tenant'ов
через DB::connection('pgsql_supplier')
Архитектура: crm_supplier_worker BYPASSRLS-роль (создана Plan 2.6 #iv 7899071)
+ новый pgsql_supplier connection в config/database.php. WHERE(tenant_id=)
фильтры сохраняются как defense-in-depth.
Уточнение по Job's $connection: оригинальный план предполагал public $connection
= 'pgsql_supplier' на RouteSupplierLeadJob, но в Laravel Job's $connection
управляет очередью (sync/database/redis), не БД. Заменено на константу
RouteSupplierLeadJob::DB_CONNECTION + явный DB::connection(self::DB_CONNECTION)
в failed() callback'е. Это:
1) не ломает queue resolution (без этой правки тесты падают
'pgsql_supplier queue connection has not been configured')
2) явно документирует intent — failed_webhook_jobs INSERT идёт через BYPASSRLS
3) handle()'s tenant-scoped транзакции остаются на default pgsql + SET LOCAL,
где RLS нужна для defense-in-depth.
Также добавлено в tests/TestCase.php разделение PDO между pgsql и
pgsql_supplier connection'ами через setPdo/setReadPdo — иначе DatabaseTransactions
не откатывал бы supplier-side данные (две PDO-сессии = две независимые транзакции,
supplier не видит uncommitted INSERTs default-side).
Brainstorm decision: вариант C из 3 опций (A=schema bump, B=отдельная таблица,
C=BYPASSRLS-role). См. docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §1.
+4 теста в Feature/Supplier/SupplierConnectionTest.php (DB_CONNECTION constant +
BLOCKER#6 + WARN#2 + WARN#3). 0 schema changes.
Pest: 562/560 + 2 skipped (baseline 558/556 + 4 new = 562/560, ok). PHPStan: 0 errors
(добавлен 1 baseline entry для известного Pest+PHPStan limitation на artisan()).
Pint: clean.
Закрывает 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
(psql -v crm_supplier_worker_password='<from-secrets>' ...).
- GRANT'ы в db/02_grants.sql секция 5.
- Queue worker .env: DB_USERNAME=crm_supplier_worker (отдельно от web .env).
Inline-warnings обновлены в LeadRouter.php + ResetDeliveredTodayCommand.php
(ссылка на crm_supplier_worker BYPASSRLS на prod, db/00_create_roles.sql).
00_create_roles.sql header bump v1.0 → v1.1 (4 → 5 ролей).
Без TDD-теста на роль (integration-тест требует CREATE ROLE в test DB +
смены connection — overhead не оправдан); smoke-grep verify пройден.
Pest 558/556 (+9 от Plan 2.5 baseline 549/547), Larastan + Pint + squawk green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Закрывает CV.11 audit WARN #4 (placeholder secret '__SET_ON_DEPLOY__' = silent
404 на production через verifySecret в SupplierWebhookController).
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 fail-fast.
TDD: 4 теста (placeholder rejected / short rejected / missing rejected / valid accepted).
phpstan-baseline +1 entry: Pest TestCall::artisan() PhpDoc-quirk (как
ResetDeliveredTodayCommandTest).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spec §6.1: ежедневный сброс projects.delivered_today=0 после midnight МСК.
delivered_in_month НЕ трогаем (это месячный счётчик, Plan 4 cron).
Реализация: Artisan-команда `projects:reset-delivered-today` (idempotent
UPDATE без транзакции/локов — отрабатывает за <1 сек), Schedule в
routes/console.php с dailyAt('00:00')->timezone('Europe/Moscow').
NB: `withoutOverlapping()` пропущен — требует таблицу cache_locks, которой
нет в schema.sql (Laravel-default-миграции удалены в фазе 1). Идемпотентность
UPDATE делает overlap-защиту избыточной.
Tests: 2/2 pass, phpstan 0 errors (1 baseline для $this->artisan, как у
прочих Pest-тестов с artisan-helper).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>