feat(mera/b2c): правовая рамка — согласие до сохранения, удаление по сроку и по запросу — этап 4 из 8 #2547
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#2547
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "feat/mera-b2c-privacy"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Четвёртый этап плана B2C. Наружу ничего не открывает, мержится независимо от DNS.
Три дефекта, каждый блокировал легальный публичный запуск
Адрес физлица сохранялся до любого согласия. Согласие фиксировалось только на форме заявки — то есть после записи адреса в базу. Для пилота с договором это терпимо, для человека с улицы нет. Теперь проверка стоит первой строкой расчёта, до геокодирования и до обоих мест записи адреса.
Срок жизни оценки не приводил к удалению. Поле срока применялось только как фильтр при чтении, физического удаления не было ни в одной из 23 фоновых задач — данные жили вечно вопреки декларации. У заявок срока не было вовсе.
Пути «удалите мои данные» не существовало.
Решения, которые стоит отметить
Хранение согласия — колонками на самой оценке, 1:1 с уже работающим прецедентом для заявок: IP клиента, версия политики, дословный снимок текста. Отдельную таблицу событий не заводили сознательно — согласие даётся ровно на создание этой строки, и когда строка удаляется по сроку, исчезновение доказательства вместе с данными логично: персональных данных больше нет, свидетельствовать не о чем.
Проверка не выводится из пустого имени пользователя. Первая версия так и делала — и сломала 92 несвязанных теста оценщика, которые вызывают расчёт без имени, проверяя ценовую логику. Заменено на явный флаг, который выставляет единственный боевой вызывающий. B2B-поток не тронут: поле согласия опционально, иначе сломались бы пилоты, чей фронт его вообще не шлёт.
Задача удаления засеяна выключенной. Это первая автоматическая задача в trade-in, которая удаляет персональные данные — первый прогон должен пройти под наблюдением, а не по расписанию. Тот же приём уже применялся в проекте. Включается одной командой после проверки.
Честно зафиксированные ограничения
Аноним без ссылки на оценку, без телефона и без обращения в поддержку — неидентифицируем. Удалить его данные без дополнительной идентификации невозможно, схема этого не позволяет. Записано в докстринге, а не умолчано.
Удаление чистит только копию в базе. Зеркало переписки в Telegram-топике не удаляется ничем в кодовой базе — нужен отдельный ручной шаг через API бота. Зафиксировано в коде.
События аудита этим механизмом не чистятся — там в полезной нагрузке есть адрес. Является ли журнал аудита законным основанием пережить запрос на удаление — вопрос к юристу, не инженерное решение.
Что за юристом
Конкретные сроки хранения — инженерное предложение с обоснованием, не юридический вывод. Для сконвертированных заявок нужен другой, договорной срок, и механизма пометки «сконвертирован» в схеме сейчас нет.
Test plan
uv run pytest -q— 2775 passed, 9 skippedruff checkчисто, проверка на ловушку:x::typeчистоЗамечание со стороны (в код ветки не лезу): коллизия номеров миграций.
Этот PR добавляет
192_trade_in_estimates_consent_proof.sqlи193_trade_in_privacy_retention.sql, но 192 и 193 уже заняты наmain—192_tradein_users_auth.sqlи193_tradein_users_seed.sql(смержены в #2597). Свободные номера на сейчас — 197+ (196 занят #2598,da329cda).Без переименования это ломает pytest-гейт деплоя уже ПОСЛЕ мержа, когда откатывать дороже. Замечено при ревью соседнего PR.
Замечание не по существу PR, а по номерам миграций — заметил, пока чинил соседнее.
Этот PR приносит
192_trade_in_estimates_consent_proof.sqlи193_trade_in_privacy_retention.sql, а вmainуже лежат192_tradein_users_auth.sqlи193_tradein_users_seed.sql. Имена файлов разные, поэтому применение не сломается и ничего не пропустится — учёт идёт по полному имени. Но номера коллидируют.Стоит перенумеровать перед мержем. Сегодня ночью в такую же коллизию въехали два моих агента (оба взяли 212, потому что каждый смотрел только на свою ветку), и заодно выяснилось, почему гейт-тест это не ловит: он сравнивает префиксы файлов в одном рабочем дереве, а чужой файл из параллельной ветки там физически отсутствует. То есть защита исправна, но кросс-веточную коллизию поймать не может по построению — она видна только после мержа, когда номер уже разошёлся.
Свободные номера на сейчас: в
mainпоследний 213, плюс заняты открытыми PR 214 (#2684) и 215 (#2685). То есть безопасный старт — с 216, но перед коммитом лучше сверить ещё раз: за ночь список подвинулся четыре раза.Отдельно завёл #2683 про то, что файл-манифест применённых миграций отстал на 24 записи и сам себе противоречит — там же предложение строить защиту от коллизий на сверке с
mainв CI, а не на манифесте.Deep review — 🟠 HIGH, merge на паузе
Гейт пройден:
CI Trade-In / backend-tests= success 3m21s на3ee99efa(явный success, не отсутствие красного). Миграции 229/231 прогнаны на живой прод-схеме вBEGIN…ROLLBACK, тело дважды — чисто. Согласие-гейт разобран, B2B не сломан. Одна находка держит мерж.🟠 HIGH — предикат удаления = 24-часовой TTL ссылки, применённый к архиву истории пилотов
app/tasks/purge_expired_trade_in_data.py:56-66удаляетtrade_in_estimates WHERE expires_at < NOW(), аexpires_at = created_at + trade_in_estimate_retention_hours(24ч) ставится каждой оценке, включая B2B.Замер на проде прямо сейчас:
max_batches × batch_size = 20 × 500 = 10000≫ 1040 → вся таблица уходит за ПЕРВЫЙ прогон, необратимо.Что при этом ломается:
GET /api/v1/trade-in/history(app/api/v1/trade_in.py:735-748) — фильтраexpires_atтам нет. Это раздел «История» в Topbar (frontend/src/components/trade-in/Topbar.tsx:153,frontend/src/app/history/page.tsx:28) и источник KPI v2-дашборда (frontend/src/components/trade-in/v2/mappers.ts:2257+). Пилоты теряют весь архив оценок.GET /team/employees/{id}/history(app/api/v1/team.py:806) —LEFT JOIN trade_in_estimates→median_price/confidence/n_analogsстановятся NULL по всей истории сотрудников.cache-stats(trade_in.py:793-805) —estimates_total/avg_median_price/repeat_address_pctсхлопываются до последних суток.Корень — смешение двух смыслов. Сегодня
expires_atэто TTL ссылки/PDF (get_estimate→ 404,estimate_pdf→ 410 «estimate expired (24h TTL)»), а не срок хранения записи. Комментарий вconfig.py:822это прямо и говорит («оценка живёт «сессию» клиента, не архив») — но продукт использует таблицу именно как архив.Ровно ту же асимметрию PR уже проводит сам:
_estimate_consent_persist_fieldsосознанно НЕ пишет доказательство согласия для B2B, потому что «согласие закрыто договором, НЕ UI-чекбоксом». Со сроком хранения аргумент симметричен: у пилота по договору свой срок, не анонимные 24 часа.Почему не 🔴: задача действительно засеяна выключенной — проверено втройне:
231:82seedfalse; kit-планировщик выбирает толькоWHERE enabled = true(packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py:730); прод-прогон подтвердилenabled=f. На деплое не запустится ничего.Почему всё-таки держим: докстринг миграции 231 (строки 36-39) выдаёт оператору однострочную команду включения и описывает первый прогон просто как «supervised», без единого слова о том, что он снесёт 911 строк пилотов. В Test plan этого же PR стоит незакрытый пункт «Первый прогон задачи удаления — под наблюдением, вручную» — то есть спусковой крючок уже стоит в очереди.
Любой из вариантов закрывает, все маленькие:
(a) сузить до популяции, ради которой PR и делался:
(129 строк сегодня, все legacy/безхозные — архив пилотов цел);
(b) развести часы: удалять по
created_at < NOW() - make_interval(days => :purge_days)с новой настройкойtrade_in_estimate_purge_days(напр. 180), оставивexpires_atкак TTL ссылки;(c) если 24ч — это и правда срок хранения записи в т.ч. для пилотов, то это продуктовое решение, которое принимается явно и с переделкой
/historyзаранее, а не обнаруживается по факту пустого архива.Независимо от выбора: перенести предупреждение в докстринг рядом с командой включения и добавить preflight-счётчик, чтобы оператор перед включением видел «будет удалено N строк, из них M с владельцем».
🟡 MEDIUM — удаление по телефону это точное сравнение строк → тихий no-op
app/services/data_erasure.py:126-131матчитphone = :phoneбуквально.lead.py:69-84хранит телефон как прислали (только regex + счёт цифр, нормализации в E.164 нет намеренно, #2376). Оператор, вводящий8 (999) 123-45-67против сохранённого+79991234567, получает{"trade_in_leads_deleted": 0}и HTTP 200 — запрос на удаление отчитывается выполненным, данные остаются. Для legal-erasure ручки тихий промах это худший из режимов отказа.(4 строки на проде, вопрос производительности не стоит)
🟢 Мелочи
231:56-64—ADD COLUMN … SET NOT NULLбез DEFAULT. Миграции применяются ДО перезапуска контейнеров, поэтому старый код, вставляющий лид в это ~минутное окно, словит NOT NULL violation. При 4 лидах в месяц вероятность пренебрежимая, ноALTER COLUMN expires_at SET DEFAULT NOW() + interval '180 days'закрывает бесплатно.data_erasure.py:11-29), Telegram-зеркало (31-40+ врезка на152-155),user_eventsне чистится (42-51). Это сделано хорошо. Не зафиксировано нигде другое: при открытии анонимного флоу адрес попадёт вgeocode_cacheчерез ручку автодополнения/геокодинга раньше чекбокса — гейт закрывает только путь/estimate. Строчка в блок_ESTIMATE_CONSENT_*пригодится следующему этапу.trade_in_estimate_retention_hours=24,trade_in_lead_retention_days=180,trade_in_purge_batch_size=500вconfig.py:817-844, все через ENV; прежние два хардкодаtimedelta(hours=24)в estimator.py убраны. Единственный литерал —interval '180 days'в backfill'е 231, и это правильно (миграция = снимок на момент применения) и там же объяснено._DEFAULT_MAX_BATCHES = 20живёт модульной константой, тогда какbatch_size— в settings. Асимметрия косметическая:default_paramsнесёт оба, переопределяется на уровне расписания.Что проверено и сходится
BEGIN…ROLLBACK, тело дважды: проход 1 — весь DDL, проход 2 — сплошь «already exists, skipping»). Seed садится какenabled=f,next_run_at 2026-08-07 02:00+00,{"batch_size":500,"max_batches":20}.ON CONFLICT (source)опирается на существующийscrape_schedules_source_key UNIQUE (source). Backfill проставил 4 лидам Dec 2026 / Jan 2027, под удаление сейчас не попадает ни один.INSERT … consent=false→violates check constraint "trade_in_estimates_consent_not_false". Работает._schema_migrationsна проде, и ни один открытый PR их не занимает (#2732 и #2742 оба взяли 232 — это их коллизия, не этого PR).require_consent = x_authenticated_user is None;/api/v1/trade-in/estimateне входит в_PUBLIC_PATHS(rbac.py:71-86), а session-cookie путь инжектитX-Authenticated-Userпрямо в ASGI scope (_propagate_authenticated_user,rbac.py:207) — значит хендлер сегодня всегда видит username, и ветка гейта недостижима на проде.consent: bool | None = Noneв схеме оставляет payload'ы пилотов (которые поле не шлют) валидными.estimate_quality(estimator.py:3504-3513), доgeocode()и до обоих INSERT (основной +_empty_estimate).schedule_event(estimate_request, payload={address,…})срабатывает уже ПОСЛЕ возврата изestimate_quality(trade_in.py:207-220) — на 422 адрес вuser_eventsне попадает.RequestAuditMiddlewareпишет толькоstatus_code, тело не логирует.account_quota.check_and_raiseпри username=None это no-op.mark_failedфиксирует накопленные счётчики, повторный запуск просто матчит меньше строк. Тесты это покрывают (test_purge_expired_trade_in_data.py:167-198).estimate_photosхранитcontent byteaв самой БД (007_estimate_photos.sql:12) — CASCADE уносит фото целиком, осиротевших файлов на диске не остаётся.RequestAuditMiddlewareпишетadmin_actionдля мутирующих/api/v1/admin/**(app/core/request_audit.py:91-100) → кто и когда запросил стирание, в журнале есть (без тела).Пересечение с платёжным контуром (#2732)
Коротко:
pd_erased_atэто заглушка, а не механизм — но расширять #2547 сейчас НЕ надо. Расширять на PR-D, до первого живого платежа.Почему не сейчас:
232_payments.sqlещё не применён (на проде_schema_migrationsзаканчивается на 230), а 231 идёт раньше 232 — задача, ссылающаяся наpayments, свалится на схеме, где таблицы ещё нет. ПлюсPAYMENTS_ENABLED/TBANK_*на проде отсутствуют, защищать сегодня нечего.Что именно и когда — три пункта, все вместе с PR-D или до него, потому что после первого живого платежа они становятся ретроактивно невыполнимыми:
payment_notifications.body— настоящая дыра.232_payments.sql:214:body jsonb NOT NULL -- полное тело нотификации как есть, append-only, без TTL и без пути стирания. При подключённой кассе в теле приезжаетReceipt.Email/Receipt.Phone. Самое дешёвое и надёжное — не сохранять: вырезать поддеревоReceiptперед INSERT (скаляры, участвующие в подписи, остаются — подпись считается по top-level полям, ср. #2733). Позже редактировать append-only хранилище дорого, не класть туда — одна строка в PR-D.payments.customer_email/customer_phoneвerase_person_data— именноUPDATE, неDELETE(фискальный след по 54-ФЗ обязан пережить:order_id/amount_kopecks/product_code/statusостаются):Здесь ловушка порядка: id лидов надо захватить ДО того, как шаг 2 их удалит — ровно та же проблема, которую модуль уже решает для оценок (
data_erasure.py:103-107). Сейчас функция собирает estimate_ids, но не lead_ids, поэтому правка в PR-D будет не чисто аддитивной. Фиксирую сейчас, чтобы не переоткрывать потом.3. TTL для
payment_notifications— это артефакт идемпотентности и отладки, не фискальный документ. Третий вызов_drain_expiredв этой же задаче, когда 232 будет применён. Естественное место, новой машинерии не требует.Отдельно, в плюс #2732 и стоит сохранить:
payments_lead_idxпоlead_idтам есть (232_payments.sql:195) с комментарием ровно про seq scan от FKON DELETE SET NULL— а это именно то, что ночная чистка лидов из этого PR и провоцировала бы. Взаимодействие двух PR здесь учтено.Дальше
CI Trade-In / backend-tests, смержу.Не мержу до этого: 911 строк пилотов (включая user2/brusnika, доступ которому вернули три коммита назад) не восстанавливаются.