Skip to content

ТЗ — 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_logid, 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-flagsfilter[…], sortwhitelist параметров (конвенция ядра §API)
API GET /api/v1/admin/feature-flags/{feature}/historycursor, 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.enabledbooltrueнетВключить UI-управление фичами
feature-flags.default_rollout_percentint0нетДефолтный процент раската для новых фич
feature-flags.log_retention_daysint180нетХранение журнала переключений
feature-flags.require_confirmation_for_full_rolloutbooltrueнетТребовать подтверждение при раскате на 100% или полном выключении у ранее включённой аудитории
feature-flags.allowed_segment_attributesarray['role','city_id','site_id']нетWhitelist атрибутов пользователя, доступных в условиях сегмента (защита от произвольных проверок)
feature-flags.unknown_feature_behaviorenum: hide/warnwarnнетПоведение UI при флаге, отсутствующем в коде (см. «Крайние случаи»)
feature-flags.max_tracked_flagsint, nullablenullнетЛимит числа отслеживаемых флагов в UI (защита от неуправляемого разрастания списка)

feature-flags.enabled=false — фактический kill-switch самого UI-управления (матрица v2.2, пункт «Kill-switch»): отдельной настройки-предохранителя не требуется, модуль сам по себе — механизм точечного аварийного отключения фич без деинсталляции их кода.

API

МетодПутьДоступНазначение
GET/api/v1/admin/feature-flagsadmin (feature-flags.view)Список фич и их таргетинг
PATCH/api/v1/admin/feature-flags/{feature}admin (feature-flags.manage)Изменение таргетинга/значения фичи
GET/api/v1/admin/feature-flags/{feature}/historyadmin (feature-flags.view)История переключений фичи (keyset-пагинация)

PATCH требует expected_updated_at (optimistic lock): расхождение с текущим значением → 409 с актуальным состоянием в теле ответа (см. «Крайние случаи», конкурентное редактирование). Неизвестная в коде и в логе feature404. Внешнего/денежного эффекта у мутации нет — 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 (если включён)событие FeatureFlagChangedoutПодписывается и пишет факт в общий аудит-лог студии
cms/attack-monitor (если включён)событие FeatureFlagChangedoutОтслеживает аномальные паттерны массовых переключений
Любой модуль, вызывающий Feature::active() в кодепрямой вызов фасада Pennant, минуя сервис этого модуляin/outcms/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-чек модуля проверяет доступность таблицы features Pennant
  • [ ] При выключении модуля ранее установленные значения фич продолжают действовать
  • [ ] Таргетинг по проценту детерминирован для одного пользователя (не мигает между запросами)
  • [ ] Права 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 запрещён

Внутренняя база знаний студии