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 переиздаёт claimtenant_id. Смена активного агентства не трогает уже созданные ресурсы — ихAgencyCompanyIdиммутабелен.
3. Выбор агентства при входе — nextAction = SelectActiveAgency
- Если у агента больше одного активного членства и активное агентство в сессии ещё не выбрано (или
выбранное перестало быть активным — увольнение), логин/refresh возвращает
nextAction = SelectActiveAgency: клиент обязан вызватьPOST /api/v1/me/active-agencyдо работы с CRM-ресурсами. - При ровно одном активном членстве активное агентство выбирается автоматически (единственный кандидат);
nextActionне выставляется. Ноль активных членств — независимый агент (scopePlatform, 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из claimtenant_idактивного агентства, а не из неявного единственного членства. В момент создания это значение снимается в иммутабельные атрибуты (OriginAgencyCompanyId, снапшотAgencyCompanyId) — сделка навсегда принадлежит тому агентству, в контексте которого создана. - Одновременная работа в нескольких агентствах — это последовательная смена активного агентства между операциями; в каждый момент времени create-* однозначно определяет tenant по активному claim.
6. Rewards — апсерт AgentPerformance по паре (AgentUserId, CompanyId)
- Проекция
AgentPerformance(ADR-0061, user-centric метрики) при мульти-членстве апсертится по паре(AgentUserId, CompanyId): у агента появляется по строке результативности на каждое агентство, где он ведёт сделки. Личная сводка агента — агрегат этих строк сквозь агентства (user-centric остаётся). Consumerpin.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-agency | 1 | новый endpoint | additive |
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.v1 | 1 | не меняется | rewards-consumer апсертит по паре (AgentUserId, CompanyId) |
План Внедрения
| Этап | Действие | Критерий успеха | Риск |
|---|---|---|---|
| 1 | Миграция AgentMembershipEntity: снять INV-AM1, ввести partial-unique SingleActivePerCompany | нет дубликатов активных по паре; старые записи проходят | дубликаты в данных до миграции |
| 2 | RefreshSession.ActiveCompanyId + backfill из текущего единственного членства | у всех активных агентов задан ActiveCompanyId | агенты без членства (независимые) |
| 3 | Endpoint POST /api/v1/me/active-agency + nextAction=SelectActiveAgency в login/refresh | смена агентства переиздаёт tenant_id; >1 членство требует выбора | клиент не обрабатывает nextAction |
| 4 | CRM create-* → AgencyCompanyId из claim; rewards AgentPerformance по паре | новые ресурсы привязаны к активному агентству | гонка смены агентства между операциями |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (5): Entity (Replica): UserDisplayReplicaEntity, Entity / Aggregate: AgentMembershipEntity, Entity / Aggregate: LeadEntity, Entity / Aggregate: SignInConfirmationRequest + RefreshSession, Entity / Projection: AgentPerformanceEntity
- API (1): POST /api/v1/me/active-agency — Выбор активного агентства
- Use Cases (5): UC-CRM-013. Увольнение агента: каскад отвязки от процессов CRM, UC-CRM-014. Агент выбирает активное агентство перед созданием лида/сделки, UC-RWD-006. Агент смотрит личную статистику за всю карьеру (user-centric), UC-RWD-017. Уволенный агент: судьба заработанных комиссий, US-реестр «Участники и владение процессами» (US-CRM-PART / US-AG-ADMIN / US-AGT-MULTI / US-INV-CO)
- События (1): Event Contract: pin.identity.agent-membership-changed (AgentMembershipChangedKafkaEvent)
- Консюмеры (3): Consumer: Agent Performance Projection, Consumer: Identity Agent Membership Changed, Consumer: Wallet Identity Display Sync (wallet-identity-users)
Связанные документы
- Затрагиваемые доки: identity/domain/user.md (семантика
CompanyId, членство), identity/domain/role.md (scope), crm/domain/deal.md, crm/domain/lead.md, crm/domain/fixation-request.md (снапшотAgencyCompanyId). - ADR: ADR-0061 (независимый агент, членство, user-centric
метрики — дополняется), ADR-0052 (
TenantId = CompanyId, инвестор вне tenant), ADR-0025 (роли/permissions/scope).