Тема
ТЗ — Feature-flags (UI) (cms/feature-flags)
Слой: 🔵 инфра-модуль · Зрелость доноров: ★★ · Донор: — (laravel/pennant — механизм уже в ядре для состояний модулей) Статус: ТЗ к разработке
Назначение и возможности
UI-надстройка над laravel/pennant (уже используемым ядром для installed/enabled/disabled модулей): управление продуктовыми фичами по роли, проценту пользователей или сегменту прямо из админки, с аудитом переключений.
- Список фич с переключателями в Filament
- Таргетинг по роли пользователя
- Таргетинг по проценту (постепенный раскат)
- Таргетинг по сегменту (произвольный набор условий на пользователя)
- История переключений (кто/когда включил-выключил, для какого сегмента)
- Программный доступ через фасад Pennant для проверки фичи в коде
- Разделение с системными флагами состояний модулей ядра (не пересекается с installed/enabled)
Зависимости и выключение
requires: ядро
Модуль не объявляет suggests/provides — работает поверх Pennant без сторонних интеграций; единственная жёсткая зависимость — ядро (RequestContext для роли/сегмента, SettingsStore для группы feature-flags).
Поведение при выключении: UI-управление фичами недоступно, ранее установленные значения флагов в Pennant продолжают действовать как есть (заморожены) — деградация управляемости, не поломка. Программный доступ через фасад Pennant в коде других модулей продолжает работать (Pennant — механизм ядра, не собственность этого модуля), меняется только возможность администрировать значения через UI.
Модель данных
Своих таблиц нет — использует таблицу features из laravel/pennant (уже в ядре); модуль добавляет только журнал переключений.
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_feature_flag_log | id, feature, user_id, previous_value, new_value, scope (json) | аудит переключений фич |
Заметки: scope — JSONB с касом 'array'; индекс по (feature, created_at) под историю переключений конкретной фичи; журнальная таблица — кандидат на BRIN по created_at при росте. Таблица features Pennant модулю не принадлежит — только чтение/запись через фасад Pennant, не raw SQL (канал 4, requires ядро). Журнал — append-only по аналогии с финансовыми/журнальными таблицами (специфика §4 стандарта): исправлений задним числом нет, ошибочное значение фиксируется новой записью с исправленным new_value.
Входные и выходные данные
Входы
| Источник | Данные/поля | Чем валидируется |
|---|---|---|
| Filament-форма (админ UI) | feature, target_type (role/percent/segment), target_value, segment (условия) | FormRequest: feature — whitelist зарегистрированных фич, target_type — enum, target_value для percent — 0–100, условия сегмента — только поля из feature-flags.allowed_segment_attributes |
API PATCH /api/v1/admin/feature-flags/{feature} | то же + expected_updated_at (optimistic lock) | FormRequest whitelist, feature — 404 если не зарегистрирована ни в коде, ни в логе |
API GET /api/v1/admin/feature-flags | filter[…], sort | whitelist параметров (конвенция ядра §API) |
API GET /api/v1/admin/feature-flags/{feature}/history | cursor, filter[from/to] | whitelist, keyset-пагинация |
Событий других модулей модуль не слушает (источник состояния — исключительно таблица features Pennant и собственные PATCH-запросы); импорта и вебхуков нет.
Выходы
| Потребитель | Данные | Формат |
|---|---|---|
| Filament UI | список фич, таргетинг, состояние | таблица/форма |
API GET /api/v1/admin/feature-flags | список фич | конверт {data, meta} |
API GET .../history | журнал переключений фичи | конверт {data, meta.next_cursor} |
Событие FeatureFlagChanged | факт изменения | payload на подписчиков (cms/audit, cms/attack-monitor) |
| Код других модулей | Feature::active('name') | bool, через фасад Pennant напрямую (не через сервис этого модуля) |
Whitelist-принцип: всё, что не перечислено во «Входах» выше (произвольные поля запроса, не зарегистрированные атрибуты сегмента, не описанные в манифесте фичи), модуль обязан отвергать — 422 на неизвестном параметре, а не молчаливый игнор.
Настройки (группа feature-flags)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
feature-flags.enabled | bool | true | нет | Включить UI-управление фичами |
feature-flags.default_rollout_percent | int | 0 | нет | Дефолтный процент раската для новых фич |
feature-flags.log_retention_days | int | 180 | нет | Хранение журнала переключений |
feature-flags.require_confirmation_for_full_rollout | bool | true | нет | Требовать подтверждение при раскате на 100% или полном выключении у ранее включённой аудитории |
feature-flags.allowed_segment_attributes | array | ['role','city_id','site_id'] | нет | Whitelist атрибутов пользователя, доступных в условиях сегмента (защита от произвольных проверок) |
feature-flags.unknown_feature_behavior | enum: hide/warn | warn | нет | Поведение UI при флаге, отсутствующем в коде (см. «Крайние случаи») |
feature-flags.max_tracked_flags | int, nullable | null | нет | Лимит числа отслеживаемых флагов в UI (защита от неуправляемого разрастания списка) |
feature-flags.enabled=false — фактический kill-switch самого UI-управления (матрица v2.2, пункт «Kill-switch»): отдельной настройки-предохранителя не требуется, модуль сам по себе — механизм точечного аварийного отключения фич без деинсталляции их кода.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/admin/feature-flags | admin (feature-flags.view) | Список фич и их таргетинг |
| PATCH | /api/v1/admin/feature-flags/{feature} | admin (feature-flags.manage) | Изменение таргетинга/значения фичи |
| GET | /api/v1/admin/feature-flags/{feature}/history | admin (feature-flags.view) | История переключений фичи (keyset-пагинация) |
PATCH требует expected_updated_at (optimistic lock): расхождение с текущим значением → 409 с актуальным состоянием в теле ответа (см. «Крайние случаи», конкурентное редактирование). Неизвестная в коде и в логе feature → 404. Внешнего/денежного эффекта у мутации нет — Idempotency-Key не требуется, идемпотентность обеспечивается optimistic lock.
Компоненты
Filament: страница управления фичами с переключателями и настройкой процента/сегмента; массовое действие «выключить выбранные» на списке; страница истории переключений с фильтром по фиче; демо-сидер зарегистрированных фич для playground/галереи (матрица v2.2, «Демо-контент»). Публичных блоков/виджетов у модуля нет — UX для посетителя формируют модули-потребители фичи (бюджет фронтенда/a11y — не применимо к этому модулю).
Команды: cms:feature-flags:list --json.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
FeatureFlagChanged | изменено значение или таргетинг фичи | feature, previous_value, new_value, actor_id |
FilterBus и provides-контракты не используются. Слушает: не подписывается на события других модулей — источник состояния внутренний (таблица features Pennant).
Таблица взаимодействий
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
Ядро: RequestContext | сервис-вызов (requires ядро) | in | Чтение роли/id пользователя для резолва таргетинга по роли/сегменту |
Ядро: SettingsStore | сервис-вызов | in | Чтение группы feature-flags из кеша (0 запросов на горячем пути) |
cms/audit (если включён) | событие FeatureFlagChanged | out | Подписывается и пишет факт в общий аудит-лог студии |
cms/attack-monitor (если включён) | событие FeatureFlagChanged | out | Отслеживает аномальные паттерны массовых переключений |
Любой модуль, вызывающий Feature::active() в коде | прямой вызов фасада Pennant, минуя сервис этого модуля | in/out | cms/feature-flags не посредник в резолве — потребители читают Pennant напрямую; модуль только администрирует значения и пишет аудит |
Page-cache ядра (CacheTags) | affectsPageCache через настройки модуля-потребителя фичи | out | См. «Производительность и кеш» — источник противоречия, разобран в «Крайних случаях» |
Фоновая работа
Изменение фичи применяется синхронно при сохранении в Filament/API, чтение — через фасад Pennant без очередей. Единственная плановая задача — очистка журнала: команда cms:feature-flags:prune-log регистрируется через ScheduleRegistrar ядра и удаляет записи cms_feature_flag_log старше feature-flags.log_retention_days (батчами, без блокировки таблицы).
Эксплуатация (матрица v2.2): метрика в Pulse — число активных флагов и частота переключений за сутки. При инциденте («флаг сломал прод») — cms:feature-flags:list --json для быстрой сверки состояния и аварийный откат через PATCH/Filament. Бэкап: cms_feature_flag_log входит в общий бэкап БД без спецобработки (append-only); таблица features Pennant восстанавливается вместе с БД — после restore администратор обязан свериться, что значения флагов соответствуют ожиданиям на момент восстановления (состояние может оказаться устаревшим относительно последующих ручных переключений, потерянных вместе с остальной БД).
Производительность и кеш
Ожидаемые объёмы: продуктовые фичи — единицы-десятки активных флагов на инсталляцию (не каталожные величины); cms_feature_flag_log при активном A/B-тестировании — порядка десятков записей в день на инсталляцию, за год без чистки — до 15–20 тыс. строк; log_retention_days (дефолт 180) держит таблицу компактной, BRIN-индекс по created_at рассчитан на этот профиль роста, не на журналы коммерческого масштаба.
Горячий путь — Feature::active(), вызывается на каждом публичном запросе, потенциально многократно за рендер (несколько фич в разных блоках): бюджет — 0 дополнительных запросов к БД на резолв сверх штатного кеша Pennant (persist-driver + in-memory на запрос), настройки группы feature-flags читаются из кеша групп (§6 стандарта).
Индексы: (feature, created_at) на cms_feature_flag_log — под выборку истории конкретной фичи без полного скана.
Что кешируется: сам резолвинг фичи — штатным механизмом Pennant, модуль не дублирует и не объявляет для этого собственных тегов CacheTags. Журнал переключений не кешируется — читается редко (страница истории), актуальность важнее скорости. При изменении значения через UI/API модуль обязан сбросить персистентный кеш Pennant для затронутого scope (Feature::flushCache()), иначе новое значение применится только после истечения TTL драйвера — несоответствие увиденному в UI.
Взаимодействие с page-cache ядра (affectsPageCache). Сама группа настроек feature-flags не рендерит страниц — её affectsPageCache у всех ключей нет. Но это не значит, что фичи безопасны для общего page-cache: если код модуля-потребителя встраивает результат Feature::active() в разметку блока, который участвует в общем (не персонализированном) page-cache, разные пользователи (разный процент/сегмент/роль) получат из кеша чужой вариант разметки. Ответственность за корректную инвалидацию/исключение из кеша лежит на модуле-потребителе, а не на cms/feature-flags — механизм и решение зафиксированы флагом personalized контракта блока (см. «Крайние случаи»).
Безопасность
Границы входа: изменение таргетинга — только под feature-flags.manage, значения роли/сегмента — FormRequest-whitelist по зарегистрированным ролям/условиям (allowed_segment_attributes), без произвольного PHP-кода в условиях сегмента. Таргетинг по проценту детерминирован (хеш по внутреннему user_id, не по клиентски подставляемому значению — cookie/заголовок), не содержит утечки PII в ключе разбиения.
Векторы атак, специфичные для модуля:
- эскалация через сегмент: без whitelist
allowed_segment_attributesусловие могло бы проверять произвольное поле пользователя (например email) — атаки на приватность через таргетинг закрыты whitelist'ом на уровне FormRequest; - подделка bucket'а раската: если бы хеш строился по клиентски управляемому значению (cookie,
X-Forwarded-*), пользователь мог бы форсировать себе включённое состояние — хеш строится только поuser_id/RequestContext; - флуд журнала/DoS через переключения:
PATCHпод rate-limit как любая admin-мутация; audit-журнал append-only исключает тихую подмену истории; - enumeration через
{feature}:GET/PATCHтребуютfeature-flags.view/.manage— неавторизованный запрос получает401/403, не раскрывает существование фичи через 404 vs 403.
ПДн-паспорт (матрица v2.2): журнал хранит user_id (ссылка на пользователя ядра, не сырые ПДн) и scope (JSON, ограничен allowed_segment_attributes — не может содержать email/телефон). Ретеншн — log_retention_days (дефолт 180). Участие в «забыть по запросу» (152-ФЗ): при удалении пользователя ядром модуль обязан анонимизировать user_id → null в своих записях журнала (не удалять факт переключения целиком — это аудиторские данные), реагируя на событие ядра об удалении пользователя. В «выгрузить всё по субъекту» попадают записи журнала, где пользователь выступал user_id — только сам факт участия в таргетинге, не содержимое сегмента других пользователей.
Матрица ролей:
| Роль | Просмотр списка/истории | Изменение таргетинга |
|---|---|---|
| studio | ✅ | ✅ |
| админ | ✅ | ✅ |
| менеджер | ✅ | ❌ |
| редактор | ❌ | ❌ |
Права: feature-flags.view, feature-flags.manage.
UX-требования
Для админа:
- пустое состояние (список фич пуст — ни одна фича ещё не была проверена в коде): подсказка «Фичи появляются здесь автоматически после первого обращения к
Feature::active()в коде — сейчас ни одна не задействована», без ложного ощущения поломки; - массовое действие на списке: выключить выбранные фичи (аварийный откат нескольких сразу);
- ошибки на человеческом языке: попытка раската на 100% без явного подтверждения — «Перед полным раскатом подтвердите изменение — оно затронет всех пользователей»; недопустимый атрибут сегмента — «Поле «email» недоступно для таргетинга, доступны: роль, город, сайт», а не текст валидатора FormRequest;
- подтверждение необратимых по влиянию (не по данным) операций: раскат на 100% и полное выключение у ранее включённой аудитории — модальное окно с явным «Продолжить?» (
require_confirmation_for_full_rollout); - визуальный сигнал «⚠️ не найдена в коде» рядом с флагом, отсутствующим в текущей версии кода, — администратор видит проблему, не тихо теряет контроль над фичей.
Для посетителя: модуль не имеет собственного UI-присутствия на публичном сайте — посетитель никогда не взаимодействует с cms/feature-flags напрямую. Видимое посетителю поведение (что именно меняется на странице) целиком определяется кодом модуля-потребителя, который вызывает Feature::active(); UX-требования к этому поведению — зона ответственности потребителя, не этого ТЗ.
Крайние случаи и типовые баги
- Флаг удалён из кода, но остаётся включённым/настроенным в БД (Pennant) → модуль не падает при отображении такой фичи в списке: помечает бейджем «неизвестная фича» согласно
unknown_feature_behavior(warn— показать с пометкой,hide— скрыть из основного списка, доступна через фильтр «показать неизвестные»), администратор может вручную удалить запись. - Кеш флагов Pennant vs общий page-cache ядра — зафиксированное решение (ревизия ядра 14.07.2026, п.7). Если значение флага меняет разметку страницы для конкретного сегмента пользователей (не «все»/ «никто»), а блок, использующий этот флаг, участвует в общем (не персонализированном) page-cache — это нарушало бы правило «страницы с персональными данными не попадают в общий page-cache» (стандарт §10): часть пользователей получила бы из кеша вариант разметки, предназначенный не для них. Разрешение: реестр
BlockRegistryполучил флагpersonalizedв контракте блока — блок, зависящий от фичи с точечным таргетингом (процент/сегмент/роль, не крайние значения 100%/0% без сегмента), обязан объявить себяpersonalized, что штатно выбивает страницу из общего page-cache по правилам ядра (кешируется каркас, персонализированный фрагмент — отдельно, island/фрагмент- кешем). Самодельная client-side догрузка JS поверх закешированной страницы как обход — не используется, раз есть штатный механизм. Флаги со значением «включено/выключено для всех» кеш-нейтральны иpersonalizedне требуют. - Двойной сабмит формы Filament (два клика «Сохранить») → второй
PATCHс тем жеexpected_updated_atполучает409(конфликт версии уже применённого первым запросом изменения), UI показывает «Уже изменено, обновите страницу» — не создаёт дублей в журнале. - Параллельное переключение одной фичи двумя админами → optimistic lock по
updated_at: второйPATCHполучает409с текущим состоянием в теле ответа, админ видит diff перед повторным сохранением поверх (матрица v2.2, «Конкурентное редактирование» — не «последний победил» молча). - Выключение модуля посреди сохранения таргетинга → операция в транзакции: либо успевает закоммититься целиком (значение применяется, дальнейшее UI-управление становится недоступно), либо откатывается — половинчатое состояние (часть условий сегмента сохранена, часть нет) не остаётся ни при каком порядке событий.
- Сбой чтения
RequestContext(роль пользователя недоступна, например анонимный визит при таргетинге по роли) → резолв деградирует к «не совпало» (фича не показывается), не бросает исключение на публичном рендере — соответствует инварианту §0 «модуль не может уронить сайт». - Пустой сегмент (
segment = {}) → трактуется однозначно как «для всех пользователей» (эквивалент отсутствия условия), поведение задокументировано явно, чтобы не было двойного толкования между «для всех» и «ни для кого». - Таргетинг по сегменту без измерения
city_id/site_id(проект без мультигорода/ мультисайта) → условие поcity_idприRequestContext.city_id === nullтрактуется как «не совпало», не как исключение — правило измерений §4 стандарта соблюдено без отдельной ветки кода. - Огромный журнал переключений (частый ручной A/B-раскат туда-обратно) → BRIN-индекс по
created_atплюс плановая очисткаcms:feature-flags:prune-logпоlog_retention_daysне дают таблице расти неограниченно даже при аномально активном использовании UI. - Отсутствие suggests-модуля → у модуля нет
suggests-зависимостей; при выключенномcms/audit/cms/attack-monitorсобытиеFeatureFlagChangedпросто не имеет подписчика — не ошибка, штатная деградация канала 1 (data-exchange.md).
Донорский код
Донор: — (новая разработка поверх laravel/pennant, уже используемого ядром). Импорта/миграции легаси-данных нет — модуль заводит фичи с нуля, привязки к донорским проектам не требуется.
Тесты и приёмка
- [ ] Контрактный тест: изменение фичи через UI отражается в проверке
Feature::active() - [ ] Health-чек модуля проверяет доступность таблицы
featuresPennant - [ ] При выключении модуля ранее установленные значения фич продолжают действовать
- [ ] Таргетинг по проценту детерминирован для одного пользователя (не мигает между запросами)
- [ ] Права
feature-flags.view/feature-flags.manageразграничивают просмотр и изменение - [ ] Все переключения фиксируются в
cms_feature_flag_logбез пропусков - [ ] Неизвестная в коде фича (есть в логе/БД, нет в коде) отображается с пометкой, не роняет UI
- [ ] Конкурентное изменение одной фичи двумя админами → второй запрос получает
409с diff - [ ] Полный раскат (100%) или полное выключение у активной аудитории требует подтверждения при
require_confirmation_for_full_rollout=true - [ ] Сегмент с атрибутом вне
allowed_segment_attributesотклоняется422 - [ ] Плановая очистка журнала удаляет записи старше
log_retention_days, не трогает свежие - [ ] Резолв флага при отсутствии
city_id/site_idв контексте не бросает исключение - [ ] Пустой
segment = {}трактуется как «для всех» — покрыто тестом на конкретное поведение - [ ] Блок, зависящий от персонализированной (не 100%/0%) фичи, объявляет
personalized: trueи не участвует в общем page-cache (ревизия ядра 14.07.2026, п.7) - [ ] «Забыть по запросу»: удаление пользователя ядром анонимизирует
user_idв журнале модуля - [ ] Контрактный набор
cms-testingпройден, пакет протестирован в testbench-изоляции - [ ] Feature-тест на каждый роут модуля; тестовая БД только
feature_flags_test,migrate:freshзапрещён