Files
portal/docs/superpowers/specs/2026-06-25-snapshot-source-routing-design.md
T
Дмитрий 7053b02c9f docs: разбор швов + план + приёмка + design-gate разблокировки смены источника
Полный комплект под фичу «матч источника по слепку, без потери лидов»:
- findings: ультра-гранулярный аудит швов денежного ядра (9 субагентов, §9a-9c)
- plans: реализация в 6 эпиков (TDD, флаг отката, порядок выката)
- runbooks: план проверки (код + глаза + деньги, GO/NO-GO)
- specs: design-gate Путь A — согласован владельцем 25.06.2026

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:57:37 +03:00

12 KiB
Raw Blame History

Design-gate: матч источника по слепку + отложенное удаление (денежное ядро)

Дата: 2026-06-25 Статус: СОГЛАСОВАНО владельцем (Дмитрий) 25.06.2026 — Путь A, флаг отката, транзакция, полная гарантия слепка для новых/возобновлённых проектов. Код ещё НЕ тронут (ждёт «делаем»). Это Task 2.0 плана 2026-06-25-source-edit-unblock-snapshot-routing.md. Основание: карта швов findings §9b/§9c (9 субагентов, построчный аудит). Что утвердить: (1) выбор Пути A/B, (2) ответы по 12 швам, (3) 3 решения по спорным. После «согласен» — можно кодить Эпик 2.


1. Проблема (одной фразой)

Сейчас поставщиковые (B1/B2/B3) проекты матчатся к лиду через живую связь project_supplier_links, которая рвётся при смене источника → хвостовые лиды теряются. Нужно матчить по слепку project_routing_snapshots, который переживает смену/удаление.


2. Развилка — РЕКОМЕНДУЮ Путь A

Путь A (рекомендуется): матч поставщиковых проектов по signal-полям слепка — так же, как уже работает DIRECT (LeadRouter.php:108-112).

  • Слепок уже хранит signal_type/signal_identifier/sms_senders/sms_keyword — данные есть.
  • Слепок специально переживает удаление проекта (нет FK на projects).
  • Матч перестаёт зависеть от supplier_project.id → пересоздание stub (§9b SEAM-B) больше не рвёт доставку.
  • Сближает матч и списание — оба на слепке (списание уже на слепке, RouteSupplierLeadJob.php:327).
  • ⚠️ Требует воспроизвести в SQL agnostic-логику sms (sender+keyword) и root-domain — детали в §3.

Путь B (отклонён): слепок хранит supplier_project_id + матч по нему + sp «живёт мёртвым».

  • ✖️ Миграция таблицы снимков (партиционированной).
  • ✖️ Больше состояний у supplier_project; не решает пересоздание stub.
  • ✖️ Дальше от текущего DIRECT-образца.

РЕШЕНИЕ К УТВЕРЖДЕНИЮ: Путь A. ☐ согласен ☐ обсудить.


3. Как именно матчить по слепку (SQL, заменяет pivot-EXISTS)

Заменяем $sourceWhere для не-DIRECT (LeadRouter.php:113-117). Лид несёт $sp->unique_key = agnostic-ключ (для sms это уже sender+keyword или sender — подтверждено §9c-Q1).

site / call: прямое сравнение (как DIRECT):

snap.signal_type = ? AND LOWER(snap.signal_identifier) = LOWER(?)   -- ? = signal_type, unique_key

sms: реконструировать agnostic-ключ из компонентов слепка и сравнить с лид-ключом:

snap.signal_type = 'sms'
AND (
  (snap.sms_senders ->> 0)
  || CASE WHEN COALESCE(snap.sms_keyword,'') <> '' THEN '+' || snap.sms_keyword ELSE '' END
) = ?                                                                -- ? = lead unique_key (= sender[+keyword])

То есть собираем sender[+keyword] из снимка ровно по формуле buildUniqueKeyAgnostic (первый отправитель + keyword). Это чинит и существующий тихий провал DIRECT+sms (§9c-Q5).

root-domain (site): лид по корню carmoney.ru должен доходить до подписчиков на субдомены (однонаправленно, §9b SEAM-«root»):

... AND ( snap.signal_identifier = LOWER(?) OR snap.signal_identifier LIKE '%.' || LOWER(?) )

где ? — корневой домен лида (через SupplierIdentifier::extractRootDomain). Обратное направление (лид-субдомен → корневой проект) НЕ добавляем — сохраняем текущую семантику.

Не меняем: регион-каскад, weightedPick, фильтр лимита/баланса — они уже на слепке/live и ортогональны источнику (§9b «хорошие новости»).


4. Ответы по 12 швам (к утверждению)

# Шов Решение
1 Двойное списание (CSV-merge) Не трогаем merge-ключ. Матч по слепку детерминирован: один физический лид → один и тот же набор проектов независимо от webhook/CSV. Страховочный тест NoDoubleChargeOnSourceChangeTest (Task 2.7) гонять до И после — если позеленел до и покраснел после, матч развёл project_id → стоп.
2 resolveOrStub новый id Решено Путём A: матч по signal-полям, не по sp.id. Пересоздание stub доставку не рвёт.
3 no_snapshot молчаливый skip Гарантировать слепок. При создании/возобновлении проекта в окне (до 18:02 на сегодня) — снять слепок на сегодня через существующий SnapshotBackfillCommand/SnapshotProjectRoutingJob-логику. Плюс тест NoSnapshotNoSilentLossTest. (Открытый под-вопрос: точная точка backfill — решить в Task реализации.)
4 Источник в офлайн-батче (LIVE) Task 2.6: collectEligibleProjects переопределяет ИЗ слепка не только лимит/дни/регионы, но и signal_identifier/sms_senders/sms_keyword.
5 sms agnostic-ключ §3 sms-SQL: реконструкция sender[+keyword] из снимка, сравнение с лид-ключом. По ->> 0 (первый отправитель).
6 root-domain §3 root-SQL: суффиксное сравнение, однонаправленно.
7 Удаление источника преждевременное Task 2.4. ВНЕШНЕЕ удаление rt-проекта у поставщика откладывать пока: нет снимка-хвоста (snapshot_date >= today с этим источником) И нет необработанных supplier_leads (processed_at IS NULL) на этот sp И pivot-count=0. Внутренний матч по Пути A от этого не зависит.
8 Терминальные подстроки ошибок Безопасно. Новый матч НЕ бросает новых ошибок — он, как DIRECT, просто возвращает 0 кандидатов (тихий не-доставщик), а не исключение. Список терминальных подстрок не трогаем.
9 Авторитетный счётчик Оставляем как есть: лимит — из snapshot.daily_limit (уже так, :327); delivered_today (live) — остаток-счётчик; delivered_count (snapshot) — для drift. Переделка их не меняет.
10 Stale pivot detach уже рвёт старый pivot СИНХРОННО при смене источника (ProjectService.php:164-167) — по Пути A это БЕЗОПАСНО (матч не зависит от pivot). Ночной батч добавляет новый. Спец-чистка не нужна сверх существующего detach.
11 sms везде по sms_senders §3 sms-SQL. Заодно чинит DIRECT+sms.
12 Транзакция вокруг смены источника Обернуть update + detach в DB::transaction; dispatch джобов — ПОСЛЕ commit (DB::afterCommit / вне транзакции). Убирает частичное состояние при сбое. Малая правка в ProjectService.

5. Три решения по спорным (к утверждению)

5.1. Флаг отката. Ввести system_settings.routing_match_by_snapshot (bool). true → новый матч по слепку; false → старый pivot-EXISTS. Оба пути в queryCandidates за if. Откат раздачи — одним переключателем без релиза. ☐ согласен.

5.2. Транзакция. Обернуть смену источника в транзакцию (шов 12), dispatch после commit. ☐ согласен.

5.3. Гарантия слепка для новых/возобновлённых проектов (шов 3). Backfill слепка на сегодня при создании/возобновлении в окне до 18:02. ☐ согласен ☐ только диагностика без backfill.


6. Что НЕ трогаем (подтверждено аудитом)

  • merge-логику CSV↔webhook (шов 1), lead_charges/supplier_lead_costs привязку (устойчивы, §9b SEAM-F).
  • tier-цену (на tenant, не на проекте), delivered_in_month.
  • регион-каскад, weightedPick, баланс-фильтр.
  • CleanupInactiveSupplierProjectsJob (TTL 180д не удалит хвост преждевременно).
  • DIRECT site/call матч (уже корректен).
  • Нормализацию телефона в CSV (латентный шов вне scope, §9c-Q3 — отдельный follow-up).

7. Резюме инвариантов, которые переделка ОБЯЗАНА сохранить

  1. Один физический лид → один набор проектов-получателей (webhook и CSV сходятся по project_id) → нет двойного списания.
  2. Хвостовой лид по старому источнику доезжает на следующий день после смены (матч по слепку).
  3. Сосед по общему источнику не страдает при смене у одного клиента.
  4. Лимит/регион из слепка; is_active/баланс из live (гибрид не ломаем).
  5. sms матчится по sms_senders[0]+keyword, не по signal_identifier.

8. После утверждения — порядок работ

  1. Этот файл → «согласовано».
  2. Эпик 0 (страховочные тесты) → Эпик 2 (ядро, под флагом 5.1) → наблюдение на стенде по плану проверки.
  3. Эпики 4/5/6 (независимы) → Эпик 3 (снятие замка) последним.

Открытые под-вопросы для этапа кода (не блокируют утверждение): точка backfill слепка (шов 3); живой пример sms-payload (§9c-Q1); multi-sender sms (§9c остаток).