Реплика Talent Identity: правила синка + Entity TalentSyncState
Дом правил репликации данных из talent.microservices.identity в контекст identity PIN (Talent
Identity — источник данных о застройщиках и их пользователях через Kafka-события; в PIN создаётся
CompanyEntity на tenant/developer и запись пользователя на основе UserEntity Talent) + дом полей
служебной сущности TalentSyncState (чекпоинты синка).
Назначение
Определяет: какие события Talent потребляются, как маппятся Talent TenantEntity → CompanyEntity и
Talent UserEntity → UserEntity(PIN), какие поля владеет синк, как обеспечиваются идемпотентность,
порядок и устойчивость к out-of-order, что происходит при блокировке/удалении на стороне Talent.
Поля целевых сущностей НЕ переописываются — см. company.md и user.md;
здесь только маппинг и правила.
Классификация (TalentSyncState)
| Поле | Значение |
|---|---|
| Контекст | identity |
| Kind | Entity (служебная, anemic) |
| Source of truth | identity (мета о синке); данные — talent.microservices.identity |
| Таблица | identity.talent_sync_states |
| Tenant isolation | не tenanted (платформенная служебная таблица) |
Потребляемые события (consumers)
Контракты внешние — фиксируются как upstream source of truth (schema registry Talent).
Идемпотентность на уровне consumer-а — по идентификатору события (EventId);
бизнес-идемпотентность — version-guard (см. правила ниже).
| Topic (Talent) | Событие | Consumer PIN | Эффект |
|---|---|---|---|
talent.identity.tenants.v1 | TenantUpsertedEvent (snapshot tenant-а: Id, Name, Type, Status, Logo, Requisites, Version) | TalentTenantUpsertedConsumer | Upsert CompanyEntity (создание Pending → Active, обновление реплика-полей) |
talent.identity.tenants.v1 | TenantBlockedEvent / TenantUnblockedEvent | TalentTenantStatusConsumer | Company.Suspend(TalentBlocked) / Resume() |
talent.identity.tenants.v1 | TenantDeletedEvent | TalentTenantStatusConsumer | Company.Archive() (soft) |
talent.identity.users.v1 | UserUpsertedEvent (snapshot: Id, TenantId, FullName, Phone, Email, Status, Version) | TalentUserUpsertedConsumer | Upsert UserEntity типа DeveloperManager |
talent.identity.users.v1 | UserBlockedEvent / UserUnblockedEvent | TalentUserStatusConsumer | User.Block() / Unblock() + отзыв Active RefreshSession (INV-S7) |
talent.identity.users.v1 | UserDeletedEvent | TalentUserStatusConsumer | User.Archive() (soft) + отзыв назначений ролей |
Каждый consumer документируется отдельным файлом identity/consumers/*.md; здесь — реестр и правила
маппинга.
Маппинг Talent tenant → CompanyEntity
Поле Talent (TenantEntity) | Поле PIN (CompanyEntity) | Правило |
|---|---|---|
Id | Id и ExternalTalentTenantId | Id PIN = Id Talent (company.md, поле Id): прямая адресация без lookup. |
Type (Developer — основной поток) | Type | Маппинг enum-в-enum; неизвестный тип Talent → событие в DLQ + алерт (unknown enum handling). Только при создании (INV-4 ImmutableType). |
Name | Name | Перезапись при каждом upsert (поле владеет синк — INV-5 SyncOwnedFields). |
LogoFileId | LogoFileId | Перезапись. |
Requisites {LegalName, Inn, Kpp, Ogrn, LegalAddress} | Requisites (owned VO) | Перезапись поэлементно. |
Status (Active/Blocked/Deleted) | Status | Active → Active; Blocked → Suspended; Deleted → Archived. Переходы — методами агрегата (guard-ы stateDiagram company.md). |
Version | ReplicaVersion | Монотонный guard (см. правила). |
| — | SyncSource | Константа TalentIdentity при создании consumer-ом. |
| — | Slug, Description, WorkspaceSettings | Локальные поля PIN, синк не трогает: Slug генерируется при создании из Name, дальше управляется PIN. |
| — | LastSyncedAt | = envelope.OccurredAt применённого события. |
Маппинг Talent user → UserEntity (PIN)
Поле Talent (UserEntity) | Поле PIN (UserEntity) | Правило |
|---|---|---|
Id | Id и ExternalTalentUserId | Id PIN = Id Talent. |
TenantId | CompanyId | Компания обязана уже существовать (порядок: consumer при отсутствии — retry с backoff, событие пользователя не теряется; см. правило R4). |
FullName {FirstName, LastName, MiddleName} | Name (owned FullName) | Перезапись (владеет синк). |
PhoneNumber | PhoneNumber | Перезапись после нормализации E.164; конфликт уникальности — правило R6. |
Email | Email | Перезапись; EmailConfirmedAt = envelope.OccurredAt (Talent считается подтвердившим). |
Status (Active/Blocked/Deleted) | Status | при создании — Invited (пользователь ещё не входил в PIN), перевод в Active при первом успешном входе; при обновлении Active → Active; Blocked → Blocked; Deleted → Archived. |
Version | ReplicaVersion | Монотонный guard. |
| — | Type | Константа DeveloperManager (пользователи Talent — менеджеры застройщиков). |
| — | ContactsManagedBy | Константа TalentSync (self-service профиля запрещён). |
| — | RegisteredVia / SyncSource | TalentSync / TalentIdentity. |
| — | PasswordHash, PinCodeHash, IsTwoFactorEnabled | Не реплицируются: секреты входа PIN локальны; менеджер входит в PIN тем же СМС-flow по реплицированному номеру. |
| — | Роли | Синк НЕ назначает ролей автоматически, кроме первичного RoleAssignment(role = developer-manager, scope = Organization(CompanyId)) при создании пользователя (дефолт; сужение до Project — вручную руководителем). |
Правила синка
| # | Правило | Реализация |
|---|---|---|
| R1 Идемпотентность | Повторная доставка события не меняет состояние | идемпотентность по идентификатору события (EventId) + бизнес-guard R2 |
| R2 Монотонность версий | Событие применяется только если payload.Version > entity.ReplicaVersion; иначе — skip + метрика talent_sync_stale_events | шаг валидации consumer-а до мутации (INV-6 company / INV-10 user) |
| R3 Snapshot-семантика | Upsert-события несут полный snapshot; consumer перезаписывает ВСЕ синк-владеемые поля, не диффы | метод агрегата ApplyTalentSnapshot(snapshot, version, occurredAt) |
| R4 Порядок tenant→user | UserUpsertedEvent при отсутствующей компании → retriable-ошибка (retry с exponential backoff, затем DLQ + алерт); компания появится своим событием | если компании ещё нет → ошибка IDENTITY_SYNC_COMPANY_NOT_FOUND → retry policy |
| R5 Локальные поля неприкосновенны | Синк никогда не пишет в поля с владельцем PIN (Slug, WorkspaceSettings, секреты, роли кроме дефолтной) | раздельные методы агрегата (ApplyTalentSnapshot vs UpdateLocal) — конфликт невозможен по построению |
| R6 Конфликт телефона | Talent-номер уже занят PIN-пользователем (инвестором): событие в DLQ + тикет заботы (ручной merge учёток; авто-merge запрещён — риск отдать чужой аккаунт) | уникальный индекс → IDENTITY_SYNC_PHONE_CONFLICT |
| R7 Удаление = архив | *DeletedEvent → soft-delete (Archived); физическое удаление PII — только регламент администратора платформы (ADR-0053) | методы Archive() |
| R8 Транзакционность | Мутация сущности + TalentSyncState + собственные исходящие события PIN (pin.identity.*) — в одном согласованном изменении (outbox) | consumer |
| R9 Начальная загрузка | Bootstrap — чтение топиков с offset 0 (compacted) либо разовый импорт по API Talent миграционным job-ом; дальнейшее — только события | ../background/talent-initial-import.md |
Поля TalentSyncState
Чекпоинт «одна строка на внешний агрегат»: диагностика отставания, ручной replay, DLQ-расследования.
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор записи. |
AggregateType | TalentAggregateType | Да | varchar(32): Tenant / User; часть unique | consumer | Тип внешнего агрегата. |
ExternalId | Guid | Да | unique ux_talent_sync_states_type_external (aggregate_type, external_id) | consumer | Id агрегата в Talent. |
LocalId | Guid | Да | index ix_talent_sync_states_local_id | consumer | Id созданной сущности PIN (= ExternalId по конвенции, хранится явно на случай смены конвенции). |
LastAppliedVersion | long | Да | >= 0 | consumer | Последняя применённая версия Talent (дублирует ReplicaVersion сущности — денормализация для мониторинга без чтения агрегатов). |
LastEventId | Guid | Да | — | envelope | EventId последнего применённого события. |
LastEventAt | DateTimeOffset | Да | index ix_talent_sync_states_last_event_at | envelope | OccurredAt последнего события (лаг синка = now − max). |
Status | TalentSyncRecordStatus | Да | varchar(32): Synced / Conflict / DeadLettered | consumer | Conflict — R6; DeadLettered — событие в DLQ, требует ручного действия. |
LastError | string? | Нет | varchar(1024) | consumer | Текст последней ошибки (без PII). |
CreatedAt / UpdatedAt | DateTimeOffset | Да | — | consumer | Штампы. |
Одна строка на (AggregateType, ExternalId) — unique-индекс; Status = Conflict/DeadLettered требует
ручного resolution через admin-API (identity.users.manage платформенного scope) с переводом обратно в
Synced. Диаграмма переходов тривиальна (Synced ↔ Conflict/DeadLettered), отдельно не приводится.
Производимые события PIN (re-publish)
Синк не является «тихим»: каждое применённое изменение публикует собственные события PIN
(CompanyUpsertedKafkaEvent → pin.identity.companies.v1, UserUpsertedKafkaEvent/UserBlockedKafkaEvent
→ pin.identity.users.v1 — дома: company.md, user.md), чтобы downstream-контексты
PIN не подписывались на топики Talent напрямую (правило границ service-boundaries.md).
Входной контракт (что синк ожидает от Talent). Строгий порядок per-key (TenantId/UserId) в партиции
топика; полный snapshot в upsert-событиях (R3); монотонно растущий Version на агрегат (R2). Нарушения
обрабатываются правилами: неизвестный Type/Status → DLQ (R2/маппинг), не растущий Version → idem-skip,
смена Type компании → DLQ (INV-4). Кросс-агрегатный порядок tenant→user не гарантируется источником —
компенсируется R4 (retry + backoff).
Исходящий контракт (что downstream может предполагать). Для каждого re-publish-события PIN: key = Id
целевой сущности (порядок per-aggregate), ContractVersion = 1, монотонная доменная Version PIN-агрегата
(не ReplicaVersion Talent), доставка at-least-once после фиксации изменения (outbox, R8). Так как компания
применяется раньше своего пользователя (R4), downstream, зависящий от компании, получает pin.identity.companies.v1
не позже связанного pin.identity.users.v1. При изменении эффективных прав (создание дефолтного назначения,
отзыв при архиве) дополнительно уходит UserAccessChangedKafkaEvent (pin.identity.access.v1, key = UserId).
Наблюдаемость, безопасность
| Аспект | Значение |
|---|---|
| Метрики | talent_sync_lag_seconds (now − LastEventAt по типам), talent_sync_stale_events_total, talent_sync_dlq_total, talent_sync_conflicts_total |
| Логи | по событию: EventId, ExternalId, Version, действие (created/updated/skipped/conflict); PII (телефон/ФИО) — маскируется |
| Трейсинг | CorrelationId из envelope пробрасывается в исходящие pin-события |
| Алерты | лаг > 5 мин; DLQ > 0; Conflict > 0 |
| Runbook | replay DLQ: исправить данные (merge учёток при R6) → повторная подача события; полная пересинхронизация — R9 job |
| Безопасность | consumers работают под system-actor (не пользовательский контекст); секреты входа не реплицируются; topic-доступ — m2m ACL Kafka |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (3): Реплика Talent Handbook: правила синка + Entity HandbookSyncState, Entity / Aggregate: CompanyEntity, Entity / Aggregate: UserEntity
- API (2): POST /api/investors/me/phone/change-request — Запрос смены телефона (requestPhoneChange), PUT /api/v1/companies/my — Обновление своей компании
- Use Cases (2): UC-IDN-004. Смена телефона / email, UC-IDN-007. Синхронизация компании и её пользователей из Talent Identity (system)
- Консюмеры (1): Consumer: Talent Identity Sync (pin-identity-talent-sync)
- Фоновые задачи (1): Background Job: talent-initial-import (первичная загрузка / bootstrap из Talent Identity)
- adr (1): ADR-0063 — справочники pin.handbook (репликация из Talent Handbook)
- index.md (2): Handbook Service, Identity Service (Pin.Identity)
- registries (1): Реестр сущностей PIN
Связанные документы
- company.md, user.md — целевые дома полей (INV-5/6, INV-4/10).
- Consumers детально: talent-identity-sync.md.
- ADR-0052, ADR-0053; service-boundaries.md («Компании/пользователи Talent → identity»).
- Точные контракты топиков Talent — schema registry
talent.microservices.identity(upstream source of truth).
Имена топиков talent.identity.tenants.v1/talent.identity.users.v1 — зафиксированное предположение,
подтверждается интеграционным контрактом при реализации.