(medium) OwnPlannedProjectUpdate без price-range валидатора → PUT может сохранить инвертированную вилку (min > max) #1362

Closed
opened 2026-06-14 18:09:10 +00:00 by bot-backend · 1 comment
Collaborator

Описание

OwnPlannedProjectCreate через @model_validator(mode="after") _check_price_range гарантирует price_min_per_m2 <= price_max_per_m2. OwnPlannedProjectUpdate (PUT /api/v1/own-projects/{id}) этого валидатора НЕ несёт — только перепроверяет unit_mix. DB CHECK (миграция 148) обеспечивает лишь неотрицательность каждой цены, не min<=max. PUT может выставить price_min_per_m2 > price_max_per_m2, и это молча сохранится. Хуже: при частичном апдейте одной границы update_own_project (services/own_projects.py:204-259) пишет поля без сверки с существующими значениями. Инвертированная вилка кормит §25.3 cannibalization-движок (own_portfolio.py) и price-comparison логику → бессмысленные ценовые полосы.

Корень

Инвариант price-range не вынесен в service-слой (где видно слияние с существующими значениями) и не задублирован на DB-уровне.

Evidence

own_project.py:81-97OwnPlannedProjectUpdate имеет только _check_unit_mix, нет _check_price_range.
own_projects.py:235-240 — пишет min/max независимо, без cross-field проверки против existing.
data/sql/148_own_planned_project.sql:84-88:

CHECK ((price_min_per_m2 IS NULL OR price_min_per_m2 >= 0)
       AND (price_max_per_m2 IS NULL OR price_max_per_m2 >= 0))

Suggested fix

Перенести инвариант в service-слой: в update_own_project вычислить эффективные new min/max (incoming если задан, иначе existing) и отклонять при effective_min > effective_max (HTTP 422). Дополнительно — model_validator на OwnPlannedProjectUpdate для случая обоих полей в одном payload, и DB CHECK (price_min IS NULL OR price_max IS NULL OR price_min <= price_max) как backstop.

Обоснование severity

medium: дефект целостности с конкретным downstream-последствием (инвертированная полоса в cannibalization-scoring), но ограниченный blast radius (own planned projects, нет финансовой транзакции). confidence 0.97.


week ревью 1 · multi-agent аудит 2026-06-14 · severity=medium · category=data-integrity

Затронутые файлы:

  • /Users/anton/Птица/gendesign/backend/app/schemas/own_project.py
  • /Users/anton/Птица/gendesign/backend/app/services/own_projects.py
  • /Users/anton/Птица/gendesign/data/sql/148_own_planned_project.sql
## Описание `OwnPlannedProjectCreate` через `@model_validator(mode="after") _check_price_range` гарантирует `price_min_per_m2 <= price_max_per_m2`. `OwnPlannedProjectUpdate` (PUT /api/v1/own-projects/{id}) этого валидатора НЕ несёт — только перепроверяет unit_mix. DB CHECK (миграция 148) обеспечивает лишь неотрицательность каждой цены, не min<=max. PUT может выставить `price_min_per_m2 > price_max_per_m2`, и это молча сохранится. Хуже: при частичном апдейте одной границы `update_own_project` (services/own_projects.py:204-259) пишет поля без сверки с существующими значениями. Инвертированная вилка кормит §25.3 cannibalization-движок (`own_portfolio.py`) и price-comparison логику → бессмысленные ценовые полосы. ## Корень Инвариант price-range не вынесен в service-слой (где видно слияние с существующими значениями) и не задублирован на DB-уровне. ## Evidence `own_project.py:81-97` — `OwnPlannedProjectUpdate` имеет только `_check_unit_mix`, нет `_check_price_range`. `own_projects.py:235-240` — пишет min/max независимо, без cross-field проверки против existing. `data/sql/148_own_planned_project.sql:84-88`: ```sql CHECK ((price_min_per_m2 IS NULL OR price_min_per_m2 >= 0) AND (price_max_per_m2 IS NULL OR price_max_per_m2 >= 0)) ``` ## Suggested fix Перенести инвариант в service-слой: в `update_own_project` вычислить эффективные new min/max (incoming если задан, иначе existing) и отклонять при `effective_min > effective_max` (HTTP 422). Дополнительно — `model_validator` на `OwnPlannedProjectUpdate` для случая обоих полей в одном payload, и DB CHECK `(price_min IS NULL OR price_max IS NULL OR price_min <= price_max)` как backstop. ## Обоснование severity medium: дефект целостности с конкретным downstream-последствием (инвертированная полоса в cannibalization-scoring), но ограниченный blast radius (own planned projects, нет финансовой транзакции). confidence 0.97. --- _week ревью 1 · multi-agent аудит 2026-06-14 · severity=**medium** · category=`data-integrity`_ Затронутые файлы: - `/Users/anton/Птица/gendesign/backend/app/schemas/own_project.py` - `/Users/anton/Птица/gendesign/backend/app/services/own_projects.py` - `/Users/anton/Птица/gendesign/data/sql/148_own_planned_project.sql`
bot-backend added the
week ревью 1
label 2026-06-14 18:09:10 +00:00
bot-backend added the
scope/backend
label 2026-06-15 11:01:52 +00:00
Owner

🔍 week ревью 1 — верификация (ПОДТВЕРЖДЕНО)

Текущее состояние кода:

  • backend/app/schemas/own_project.py:81-97OwnPlannedProjectUpdate несёт только _check_unit_mix (field_validator), нет _check_price_range. Сравн. OwnPlannedProjectCreate._check_price_range (own_project.py:70-78).
  • backend/app/services/own_projects.py:235-240update_own_project пишет price_min_per_m2 / price_max_per_m2 независимо, без cross-field сверки и без слияния с existing.
  • data/sql/148_own_planned_project.sql:84-88 — guarded CHECK обеспечивает только неотрицательность каждой цены (>= 0), не min <= max.

Корень: инвариант price-range живёт только в OwnPlannedProjectCreate (Pydantic) и не задублирован ни в service-слое (где видно слияние partial-update с existing), ни на DB-уровне. PUT может сохранить инвертированную вилку (min > max) молча; при частичном апдейте одной границы вилка инвертируется относительно existing. Инвертированная полоса кормит §25.3-движок каннибализации (own_portfolio.py).

Что править:

  • Перенести инвариант в update_own_project (own_projects.py): вычислить эффективные new min/max (incoming если задан, иначе existing из БД) и отклонять effective_min > effective_max → HTTP 422.
  • Дополнительно @model_validator(mode="after") на OwnPlannedProjectUpdate для случая обоих полей в одном payload.
  • DB backstop: миграцией добавить guarded CHECK (price_min_per_m2 IS NULL OR price_max_per_m2 IS NULL OR price_min_per_m2 <= price_max_per_m2).

Что посмотреть:

  • Код: own_project.py:70-97, own_projects.py:204-259 (особенно :235-240), миграция-наследник 148_own_planned_project.sql:77-91. SQL писать через CAST(:x AS type) (psycopg v3). Тест: PUT с инвертированной вилкой + partial-update одной границы → 422.
  • Vault: релевантных заметок не найдено.
**🔍 week ревью 1 — верификация (ПОДТВЕРЖДЕНО)** **Текущее состояние кода:** - `backend/app/schemas/own_project.py:81-97` — `OwnPlannedProjectUpdate` несёт только `_check_unit_mix` (field_validator), **нет** `_check_price_range`. Сравн. `OwnPlannedProjectCreate._check_price_range` (`own_project.py:70-78`). - `backend/app/services/own_projects.py:235-240` — `update_own_project` пишет `price_min_per_m2` / `price_max_per_m2` независимо, без cross-field сверки и без слияния с existing. - `data/sql/148_own_planned_project.sql:84-88` — guarded CHECK обеспечивает только неотрицательность каждой цены (`>= 0`), не `min <= max`. **Корень:** инвариант price-range живёт только в `OwnPlannedProjectCreate` (Pydantic) и не задублирован ни в service-слое (где видно слияние partial-update с existing), ни на DB-уровне. PUT может сохранить инвертированную вилку (min > max) молча; при частичном апдейте одной границы вилка инвертируется относительно existing. Инвертированная полоса кормит §25.3-движок каннибализации (`own_portfolio.py`). **Что править:** - Перенести инвариант в `update_own_project` (`own_projects.py`): вычислить эффективные new min/max (incoming если задан, иначе existing из БД) и отклонять `effective_min > effective_max` → HTTP 422. - Дополнительно `@model_validator(mode="after")` на `OwnPlannedProjectUpdate` для случая обоих полей в одном payload. - DB backstop: миграцией добавить guarded CHECK `(price_min_per_m2 IS NULL OR price_max_per_m2 IS NULL OR price_min_per_m2 <= price_max_per_m2)`. **Что посмотреть:** - Код: `own_project.py:70-97`, `own_projects.py:204-259` (особенно `:235-240`), миграция-наследник `148_own_planned_project.sql:77-91`. SQL писать через `CAST(:x AS type)` (psycopg v3). Тест: PUT с инвертированной вилкой + partial-update одной границы → 422. - Vault: релевантных заметок не найдено.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#1362
No description provided.