🔒 feat(channels): enforce policy invariants P1-P5

SPEC-HUB-0027 §3.2/§12, этап 3. Инварианты включаются на изменении, а не
только на создании: непродуктовый канал больше не может нести checkout,
attribution или обязательную идентичность.

Проверка идёт по итоговому состоянию, а не по переданным полям. Поэтому
снять продукт и выключить коммерческие флаги можно одним запросом, а запрос,
оставляющий канал в запрещённой комбинации, отклоняется целиком — имя из
того же PATCH тоже не сохраняется.

Данные приведены заранее, иначе первый же PATCH имени упёрся бы в ошибку
политики. Отчёт нашёл один нарушитель — непродуктовый канал edevs с
включёнными checkout и attribution. По решению владельца оба флага сняты,
продукт не назначался. Та же миграция чинит дефолты модели, которые сами
нарушали P1-P2: канал создаётся без продукта, а attribution и checkout были
включены по умолчанию.

Нарушения P3-P5 миграция не исправляет, а останавливает выкат с перечнем
каналов: снятие обязательной идентичности меняет смысл канала.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 4.8 committed 2026-07-21 13:06:20 +03:00
1 parent cedd7d44f7
commit 2b97f08b19
5 files changed
+254 -8

No files matched your search

@@ -0,0 +1,62 @@
# SPEC-HUB-0027 §3.2/§12, ADR-HUB-0037 §7 — этап 3.
#
# Приводит существующие каналы в соответствие P1-P5 и меняет дефолты модели,
# которые сами по себе нарушали P1-P2: канал создаётся без продукта, а
# attribution и checkout были включены по умолчанию.
#
# Решение владельца по унаследованным непродуктовым каналам: выключить
# checkout и attribution, а не назначать продукт. Поэтому миграция чинит ровно
# P1 и P2. Нарушения P3-P5 она не трогает и останавливает выкат: их
# исправление меняет смысл канала (обязательная идентичность), и выбирать за
# владельца здесь нельзя.
from django.db import migrations, models
def relax_non_product_channels(apps, schema_editor):
Channel = apps.get_model("channels", "Channel")
blocked = list(
Channel.objects.filter(requires_authenticated_product_identity=True)
.filter(
models.Q(product__isnull=True)
| models.Q(allow_anonymous_sessions=True)
| models.Q(allow_self_reported_contact=True)
)
.values_list("id", "code")
)
if blocked:
listed = ", ".join(f"{code} (id={channel_id})" for channel_id, code in blocked)
raise RuntimeError(
"Каналы нарушают P3-P5 и требуют решения владельца до включения "
f"инвариантов: {listed}"
)
Channel.objects.filter(product__isnull=True).filter(
models.Q(allow_checkout_actions=True) | models.Q(allow_sales_attribution=True)
).update(allow_checkout_actions=False, allow_sales_attribution=False)
def noop(apps, schema_editor):
# Прежние значения не восстанавливаются: они были нарушением инварианта.
pass
class Migration(migrations.Migration):
dependencies = [
('channels', '0004_remove_channel_ai_fields'),
]
operations = [
migrations.AlterField(
model_name='channel',
name='allow_checkout_actions',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='channel',
name='allow_sales_attribution',
field=models.BooleanField(default=False),
),
migrations.RunPython(relax_non_product_channels, noop),
]
+6 -4
View File
@@ -16,13 +16,15 @@ class Channel(models.Model):
# LLM-провайдер канала (ADR-HUB-0020).
provider_integration = models.ForeignKey("integrations.Integration", on_delete=models.PROTECT, related_name="channels", null=True, blank=True)
is_active = models.BooleanField(default=True)
# Политика канала (SPEC-HUB-0010 §4.2). Значения по умолчанию соответствуют
# поведению публичных sales-каналов; support-каналы переключают флаги при seed.
# Политика канала (SPEC-HUB-0010 §4.2, SPEC-HUB-0027 §3.2). Дефолты обязаны
# удовлетворять инвариантам P1-P5 при product = null: канал создаётся без
# продукта, поэтому коммерческие флаги по умолчанию выключены. Продуктовый
# канал включает их явно — пресетом SALES или политикой в запросе.
requires_authenticated_product_identity = models.BooleanField(default=False)
allow_anonymous_sessions = models.BooleanField(default=True)
allow_self_reported_contact = models.BooleanField(default=True)
allow_sales_attribution = models.BooleanField(default=True)
allow_checkout_actions = models.BooleanField(default=True)
allow_sales_attribution = models.BooleanField(default=False)
allow_checkout_actions = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
+11 -4
View File
@@ -170,11 +170,11 @@ def update_channel(
*, context: TenantContext, channel: Channel, update: ChannelUpdate
) -> Channel:
"""Порядок обработки §6.5: блокировка, права по каждому полю, целевое
состояние, конфликты, одна транзакция.
состояние, инварианты, конфликты, одна транзакция.
Инварианты P1-P5 на изменении включаются этапом 3: до устранения нарушений
в существующих данных первый же PATCH имени упёрся бы в ошибку политики
(SPEC §12). На создании они действуют уже сейчас — см. create_channel.
Инварианты проверяются по итоговому состоянию, а не по переданным полям:
выключить продукт и коммерческие флаги можно одним запросом, а вот запрос,
оставляющий канал в запрещённой комбинации, отклоняется целиком.
"""
locked = Channel.objects.select_for_update().get(
id=channel.id, organization_id=context.organization_id
@@ -232,6 +232,13 @@ def update_channel(
setattr(locked, name, value)
changed.append(name)
# Инварианты по целевому состоянию — до записи: нарушение отклоняет запрос
# целиком, частичного применения не остаётся даже в памяти (§3.2, §6.5).
require_valid_policy(
policy=ChannelPolicy.from_channel(locked),
has_product=locked.product_id is not None,
)
if changed:
locked.save(update_fields=[*changed, "updated_at"])
return locked
@@ -0,0 +1,94 @@
"""SPEC-HUB-0027 §12 — приведение данных перед включением инвариантов P1-P5.
Схема между `channels.0004` и `channels.0005` не меняется: `AlterField` правит
только Python-дефолты. Весь риск миграции сосредоточен в функции приведения
данных, поэтому она проверяется напрямую на реальном реестре моделей — это
честнее, чем поднимать историческое состояние, где `identity` откатывается
рассинхронно со схемой БД.
"""
import importlib
from django.apps import apps
from django.test import TestCase
from hub_platform.channels.models import Channel
from hub_platform.identity.models import Organization
from hub_platform.products.models import Product
migration = importlib.import_module(
"hub_platform.channels.migrations.0005_enforce_policy_invariants"
)
class PolicyInvariantMigrationTests(TestCase):
def setUp(self) -> None:
self.organization = Organization.objects.create(name="Edevs", slug="edevs")
self.product = Product.objects.create(
organization=self.organization, code="foxray", name="FoxRay"
)
def _channel(self, code: str, **fields) -> Channel:
return Channel.objects.create(
organization=self.organization, code=code, name=code, **fields
)
def _run(self) -> None:
migration.relax_non_product_channels(apps, None)
def test_clears_commercial_flags_on_non_product_channels(self) -> None:
# Ровно случай канала edevs: непродуктовый, но с checkout и attribution.
violating = self._channel(
"edevs", allow_checkout_actions=True, allow_sales_attribution=True
)
self._run()
violating.refresh_from_db()
self.assertFalse(violating.allow_checkout_actions)
self.assertFalse(violating.allow_sales_attribution)
# Остальные флаги непродуктового канала не трогаются.
self.assertTrue(violating.allow_anonymous_sessions)
self.assertTrue(violating.allow_self_reported_contact)
def test_leaves_product_channels_untouched(self) -> None:
product_channel = self._channel(
"foxray-sales",
product=self.product,
allow_checkout_actions=True,
allow_sales_attribution=True,
)
self._run()
product_channel.refresh_from_db()
self.assertTrue(product_channel.allow_checkout_actions)
self.assertTrue(product_channel.allow_sales_attribution)
def test_stops_the_rollout_on_p3_p5_violations(self) -> None:
# Обязательная идентичность без продукта — смена смысла канала,
# выбирать за владельца нельзя, поэтому выкат останавливается.
self._channel("broken", requires_authenticated_product_identity=True)
untouched = self._channel(
"edevs", allow_checkout_actions=True, allow_sales_attribution=True
)
with self.assertRaises(RuntimeError) as error:
self._run()
self.assertIn("broken", str(error.exception))
untouched.refresh_from_db()
self.assertTrue(untouched.allow_checkout_actions)
def test_is_idempotent(self) -> None:
self._channel("edevs", allow_checkout_actions=True)
self._run()
self._run()
self.assertEqual(
Channel.objects.filter(
product__isnull=True, allow_checkout_actions=True
).count(),
0,
)
@@ -577,3 +577,84 @@ class ChannelCountersTests(ChannelApiTestCase):
channel = _make_channel(self.organization, code="main", name="Основной")
response = self.client.get(f"/api/v1/channels/{channel.id}/counters/?period=1y")
self.assertEqual(response.status_code, 400)
class ChannelPolicyInvariantTests(ChannelApiTestCase):
"""§3.2 — P1-P5 на изменении (этап 3)."""
def setUp(self) -> None:
super().setUp()
self.orphan = _make_channel(
self.organization, code="partners", name="Партнёрская линия"
)
self.product_channel = _make_channel(
self.organization,
code="foxray-sales",
name="FoxRay — продажи",
product=self.product,
allow_sales_attribution=True,
allow_checkout_actions=True,
)
def test_rejects_commercial_flag_on_non_product_channel(self) -> None:
response = self.patch_channel(
self.orphan.id, policy={"allowCheckoutActions": True}
)
self.assertEqual(response.status_code, 400)
self.assertEqual(
{item["rule"] for item in response.json()["violations"]}, {"P1"}
)
self.orphan.refresh_from_db()
self.assertFalse(self.orphan.allow_checkout_actions)
def test_rename_of_compliant_channel_is_not_blocked_by_policy(self) -> None:
# Ровно тот сценарий, ради которого этап 3 шёл после приведения данных.
response = self.patch_channel(self.orphan.id, name="Партнёры")
self.assertEqual(response.status_code, 200)
def test_detaching_product_names_the_flags_to_turn_off(self) -> None:
response = self.patch_channel(self.product_channel.id, productId=None)
self.assertEqual(response.status_code, 400)
fields = {item["field"] for item in response.json()["violations"]}
self.assertEqual(fields, {"allowCheckoutActions", "allowSalesAttribution"})
self.product_channel.refresh_from_db()
self.assertEqual(self.product_channel.product_id, self.product.id)
def test_detaching_product_with_flags_off_succeeds_in_one_request(self) -> None:
# Инварианты считаются по итоговому состоянию, а не по переданным полям.
response = self.patch_channel(
self.product_channel.id,
productId=None,
policy={"allowCheckoutActions": False, "allowSalesAttribution": False},
)
self.assertEqual(response.status_code, 200)
self.product_channel.refresh_from_db()
self.assertIsNone(self.product_channel.product_id)
self.assertFalse(self.product_channel.allow_checkout_actions)
def test_partial_application_is_impossible(self) -> None:
response = self.patch_channel(
self.orphan.id,
name="Новое имя",
policy={"allowSalesAttribution": True},
)
self.assertEqual(response.status_code, 400)
self.orphan.refresh_from_db()
# Имя не сохранилось вместе с отклонённым флагом.
self.assertEqual(self.orphan.name, "Партнёрская линия")
self.assertFalse(self.orphan.allow_sales_attribution)
def test_authenticated_identity_requires_disabling_anonymous_flags(self) -> None:
response = self.patch_channel(
self.product_channel.id,
policy={"requiresAuthenticatedProductIdentity": True},
)
self.assertEqual(response.status_code, 400)
self.assertEqual(
{item["rule"] for item in response.json()["violations"]}, {"P4", "P5"}
)