Перейти к основному содержимому

ADR-0070: Мульти-агентство и контекст активного агентства

Статус

Accepted

Дата

2026-07-16

Контекст

ADR-0061 развязал членство агента и агентства (ослабление INV-2, агрегат CompanyInvitation, независимый агент, переходы become-agent/become-investor). Но модель по-прежнему исходит из одного активного членства: агент состоит максимум в одном агентстве, а tenant_id в токене однозначно выводится из этого единственного членства. Реальная практика этого не покрывает:

  • Агент работает сразу в нескольких агентствах. Требование владельца (аналогия «игрок в нескольких командах»): один и тот же UserId может одновременно состоять в двух и более агентствах и вести сделки в каждом из них. Метрики агента уже user-centric (ADR-0061, п.5), но операционная привязка сделок к агентству — нет.
  • Инвариант единственного активного членства (INV-AM1). AgentMembershipEntity сейчас допускает не более одной активной записи на агента; это блокирует параллельное членство. Снять его нужно так, чтобы не разрешить дубликаты по паре агент+агентство и не сломать rewards-consumer, который агрегирует AgentPerformance по членствам.
  • tenant_id выводится неявно. ADR-0052 фиксирует TenantId = CompanyId: каждый CRM-ресурс живёт в tenant агентства. При одном членстве tenant токена очевиден. При нескольких активных членствах платформе нужен явный, устойчивый (durable) выбор: в контексте какого агентства агент сейчас действует и, значит, какой tenant_id попадает в claim и в снапшот AgencyCompanyId создаваемых лидов/сделок/фиксаций.
  • Зеркало UserEntity.CompanyId. Сегодня это поле — единственная «текущая компания» агента и используется как быстрый указатель в identity/UI. При мульти-членстве оно перестаёт означать «единственное агентство» и должно означать «текущее выбранное».

Решение

1. Снятие INV-AM1 — несколько активных членств

  • INV-AM1 (единственное активное членство) снимается. Агент может иметь несколько активных AgentMembershipEntity одновременно — по одному на каждое агентство.
  • Взамен вводится инвариант SingleActivePerCompany: не более одной активной записи на пару (agent_user_id, company_id) — уникальный partial-индекс unique (agent_user_id, company_id) where status = Active. Повторный вход в то же агентство реактивирует/переиспользует запись, а не плодит дубликаты.
  • Увольнение по-прежнему выражается не новым значением Status (это сломало бы rewards-consumer, ADR-0061), а enrich-ем события pin.identity.agent-membership-changed.v1 полями Reason (LeaveReason: SelfLeave / ExcludedByAgency) + EffectiveAt — additive, ContractVersion не бампится.

2. Активное агентство — durable-выбор в сессии

  • В сессию агента вводится устойчивый указатель RefreshSession.ActiveCompanyId: в контексте какого агентства агент сейчас действует. Выбор durable — переживает refresh и повторные входы, хранится на refresh-сессии, а не только в короткоживущем access-токене.
  • Refresh переиздаёт claim tenant_id из выбранного агентства. При обновлении токена tenant_id берётся из ActiveCompanyId соответствующей активной AgentMembershipEntity, а не выводится из единственного членства. Это сохраняет контракт ADR-0052 (TenantId = CompanyId) при нескольких членствах.
  • Смена активного агентства — endpoint POST /api/v1/me/active-agency (тело несёт целевой CompanyId). Операция валидирует, что у агента есть активное членство в этом агентстве, обновляет RefreshSession.ActiveCompanyId и зеркало (см. п.4); последующий refresh переиздаёт claim tenant_id. Смена активного агентства не трогает уже созданные ресурсы — их AgencyCompanyId иммутабелен.

3. Выбор агентства при входе — nextAction = SelectActiveAgency

  • Если у агента больше одного активного членства и активное агентство в сессии ещё не выбрано (или выбранное перестало быть активным — увольнение), логин/refresh возвращает nextAction = SelectActiveAgency: клиент обязан вызвать POST /api/v1/me/active-agency до работы с CRM-ресурсами.
  • При ровно одном активном членстве активное агентство выбирается автоматически (единственный кандидат); nextAction не выставляется. Ноль активных членств — независимый агент (scope Platform, ADR-0061), CRM агентства недоступен, nextAction не про агентство.

4. Зеркало UserEntity.CompanyId = «текущее выбранное»

  • Семантика UserEntity.CompanyId переопределяется: это текущее выбранное агентство (зеркало RefreshSession.ActiveCompanyId), а не «единственное». Поле остаётся быстрым указателем для identity/UI и синхронизируется при POST /api/v1/me/active-agency.
  • Историческая привязка сделок/лидов/фиксаций (снапшоты AgencyCompanyId/OriginAgencyCompanyId, ADR-0061 п.4) не пересчитывается при смене активного агентства.

5. CRM-операции create-* берут агентство из active-agency claim

  • Операции создания CRM-ресурсов (create-lead / create-deal / create-fixation и т. п.) берут AgencyCompanyId из claim tenant_id активного агентства, а не из неявного единственного членства. В момент создания это значение снимается в иммутабельные атрибуты (OriginAgencyCompanyId, снапшот AgencyCompanyId) — сделка навсегда принадлежит тому агентству, в контексте которого создана.
  • Одновременная работа в нескольких агентствах — это последовательная смена активного агентства между операциями; в каждый момент времени create-* однозначно определяет tenant по активному claim.

6. Rewards — апсерт AgentPerformance по паре (AgentUserId, CompanyId)

  • Проекция AgentPerformance (ADR-0061, user-centric метрики) при мульти-членстве апсертится по паре (AgentUserId, CompanyId): у агента появляется по строке результативности на каждое агентство, где он ведёт сделки. Личная сводка агента — агрегат этих строк сквозь агентства (user-centric остаётся). Consumer pin.identity.agent-membership-changed.v1 продолжает работать без изменения ContractVersion.

Область Действия

ОбластьВходитНе входит
Сервисыidentity (снятие INV-AM1, RefreshSession.ActiveCompanyId, endpoint active-agency, зеркало), crm (create-* берут агентство из claim), rewards (AgentPerformance по паре)chessboard, catalog, messenger, notifications
КонтрактыPOST /api/v1/me/active-agency, nextAction = SelectActiveAgency, семантика tenant_id claim при мульти-членствеконтракт pin.identity.agent-membership-changed.v1 не меняется (уже enrich-ится в ADR-0061)
ДанныеAgentMembershipEntity (partial-unique SingleActivePerCompany), RefreshSession.ActiveCompanyId, UserEntity.CompanyId (переопределение семантики), AgentPerformance ключ (AgentUserId, CompanyId)пересчёт снапшотов AgencyCompanyId/OriginAgencyCompanyId (запрещён)
Эксплуатациямиграция снятия INV-AM1 → SingleActivePerCompany; backfill ActiveCompanyId из единственного членствабанковская интеграция, промо

Варианты

ВариантОписаниеПлюсыМинусыПричина решения
Активное агентство в сессии (принято)Durable RefreshSession.ActiveCompanyId; refresh переиздаёт tenant_id; явный выбор при >1 членствеУстойчивый выбор, tenant токена детерминирован, совместимо с ADR-0052; create-* однозначныТребует шага выбора агентства и синхронизации зеркалаПринято: явный durable-контекст, tenant не «плавает» между запросами
Жёсткое одно-членствоОставить INV-AM1: агент — максимум в одном агентствеПростейшая модель, tenant очевиденНе покрывает требование: агент не может работать в нескольких агентствах одновременноОтклонено: прямо противоречит бизнес-требованию мульти-членства
tenant_id в заголовке запросаКлиент передаёт целевое агентство в HTTP-заголовке каждого запросаБез durable-состояния, гибко на уровне запросаМенее безопасно: tenant задаётся клиентом на каждый запрос (риск подмены/эскалации), нет серверной привязки к сессии, легче ошибиться в create-*Отклонено: выбор tenant должен быть серверным и валидироваться против активных членств

Последствия

ТипПоследствиеКомпенсация
АрхитектураINV-AM1 снят → SingleActivePerCompany; tenant_id claim выводится из явного ActiveCompanyId, а не из единственного членстваPartial-unique индекс + серверная валидация active-agency
БезопасностьСмена tenant — только через серверный endpoint с проверкой активного членства; заголовочный tenant отвергнутВалидация ActiveCompanyId против активных AgentMembershipEntity на каждом refresh
ДанныеUserEntity.CompanyId меняет семантику на «текущее выбранное»; AgentPerformance ключуется парой (AgentUserId, CompanyId)Backfill ActiveCompanyId из текущего единственного членства; consumer без бампа ContractVersion
ЭксплуатацияЛогин агента с >1 членством требует SelectActiveAgency до CRM-операцийКлиенты обрабатывают nextAction; при 1 членстве выбор автоматический

Затронутые Контракты И Совместимость

КонтрактВерсияСовместимостьМиграция
POST /api/v1/me/active-agency1новый endpointadditive
nextAction = SelectActiveAgency (login/refresh)additive значениеклиенты добавляют обработку
tenant_id claim (вывод из ActiveCompanyId)семантика при мульти-членствеbackfill ActiveCompanyId
INV-AM1 → SingleActivePerCompany в AgentMembershipEntityослабление инварианта (breaking для модели)partial-unique (agent_user_id, company_id) where status=Active
pin.identity.agent-membership-changed.v11не меняетсяrewards-consumer апсертит по паре (AgentUserId, CompanyId)

План Внедрения

ЭтапДействиеКритерий успехаРиск
1Миграция AgentMembershipEntity: снять INV-AM1, ввести partial-unique SingleActivePerCompanyнет дубликатов активных по паре; старые записи проходятдубликаты в данных до миграции
2RefreshSession.ActiveCompanyId + backfill из текущего единственного членствау всех активных агентов задан ActiveCompanyIdагенты без членства (независимые)
3Endpoint POST /api/v1/me/active-agency + nextAction=SelectActiveAgency в login/refreshсмена агентства переиздаёт tenant_id; >1 членство требует выбораклиент не обрабатывает nextAction
4CRM create-* → AgencyCompanyId из claim; rewards AgentPerformance по пареновые ресурсы привязаны к активному агентствугонка смены агентства между операциями

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы