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

Реестр инвариантов PIN

Ключевые доменные инварианты и переходы состояний. Дом инварианта — раздел «Инварианты и переходы состояний» в docs/06-services/<context>/domain/<entity>.md; сложные — отдельным документом по domain-invariant-template.

Формат: | ID (INV-<PFX>-NNN) | Инвариант | Сущность | Где проверяется | Документ |

catalog, promotion, dev-stats собственных INV-* не объявляют (проекции/справочники/ ClickHouse-facts — согласованность через upsert-идемпотентность и валидацию команд).

Известные заранее (из источников; детализация по батчам)

IDИнвариантКонтекстИсточник
INV-CRM-001На пару (ExecutorAgentUserId,PrimaryInvestorUserId) — не более одного лида Activecrmlead.md
INV-FIX-001На тройку (OriginAgentUserId,InvestorUserId,DeveloperCompanyId) — не более одной активной фиксации; тройка на иммутабельном инициаторе, не на исполнителе (ADR-0071)crmfixation-request.md
INV-BKG-001Резерв/Бронь имеют таймер; по истечении — событие в чат и решение менеджераcrmТЗ.md:465–473
INV-CHS-001Статусы объекта: Свободен→Резерв→Бронь→Продан; Скрыт — вне витриныchessboardТЗ.md:171–175, 465+
INV-REF-001Реферальная связь не создаётся, если Имя заполнено и регистрация >4 часовreferralТЗ.md:335–345
INV-REF-002Лид не создаётся, если создатель ссылки не агент/застройщикreferral/crmТЗ.md:343
INV-SEL-001Копия подборки в посте иммутабельна (блок можно только заменить)selections/newsТЗ.md:281–283
INV-MSG-001Правка/удаление сообщения ≤1 минуты; история правок хранится, не показываетсяmessengerТЗ.md:305–307
INV-OWN-001InvestorId = OwnerId (сопоставление по PhoneNumber)investor-ownerТЗ.md:229–231
INV-IDN-001Профиль менеджера застройщика не редактируется в PIN (источник — Talent)identity/profileТЗ.md:77

По контекстам

identity

IDИнвариантСущностьГде проверяетсяДокумент
INV-S1Один Pending на (Phone,Purpose); новый гасит прежний (Superseded)SignInConfirmationRequestrequest-handleruser-session.md
INV-S2После 5 неверных кодов — ExhaustedSignInConfirmationRequestconfirm-handleruser-session.md
INV-S3Confirm только при Pending и ExpiresAt > nowSignInConfirmationRequestconfirm-handleruser-session.md
INV-S4≤ 1 отправка кода/60 с и ≤ 5/час на номер+IPSignInConfirmationRequestrequest-handler + Redisuser-session.md
INV-S5Сравнение кодов constant-time по CodeHashSignInConfirmationRequestметод Confirmuser-session.md
INV-S6Использование Rotated refresh → отзыв всей цепочкиRefreshSessionrefresh-handleruser-session.md
INV-S7Смена пароля/телефона/блокировка → отзыв Active-сессийRefreshSessionhandler операцииuser-session.md
INV-S8NewEmail/NewPhoneNumber обязательны/запрещены по PurposeSignInConfirmationRequestфабрикаuser-session.md
INV-R1IsSystem роль неизменяема компаниейRoleDefinitionguard агрегатаrole.md
INV-R2Роль с CompanyId назначается только в scope своей компанииRoleAssignmentRoleAssignment.Createrole.md
INV-R3Platform-scope только для системных ролей (investor/agent/pin-manager/care)RoleAssignmentRoleAssignment.Createrole.md
INV-R4Комбинация полей ResourceScope строго по TypeResourceScopeконструктор VOrole.md
INV-R5Archive() роли отзывает все Active-назначенияRoleDefinitionметод + handlerrole.md
INV-R6≤ 1 Active-назначение на (user, role, scope)RoleAssignmentunique + Any(spec)role.md
INV-R7GrantPermission — только для существующего PermissionDefinitionRoleDefinitionhandlerrole.md
INV-R8Выдающий имеет role-assignments.manage в покрывающем scopeRoleAssignmentauthorization behaviorrole.md
INV-P1Permissions создаются/меняются только SEED-миграциейPermissionDefinitionнет write-APIpermission.md
INV-P2Code иммутабелен (переименование = deprecate + новый)PermissionDefinitionправило релизаpermission.md
INV-P3Code-pattern; <context>-сегмент = ContextPermissionDefinitionSEED-валидацияpermission.md
INV-P4В роли компании — только IsAssignableByCompanies и не deprecatedPermissionDefinitionGrantPermissionpermission.md
INV-CI1Переход только из Pending; терминальные иммутабельныCompanyInvitationEntityguard методовcompany-invitation.md
INV-CI2Accept только если телефон == PhoneNumber приглашенияCompanyInvitationEntityaccept-handlercompany-invitation.md
INV-CI3≤ 1 Pending на (CompanyId,PhoneNumber,TargetRoleId)CompanyInvitationEntityunique partial + Anycompany-invitation.md
INV-CI4Accept/Decline только при now < ExpiresAtCompanyInvitationEntityвалидация + jobcompany-invitation.md
INV-CI5Revoke — только актором приглашающей компанииCompanyInvitationEntityauthorization behaviorcompany-invitation.md
INV-CI6Создание только при Company.Status = Active, Type ∈ {Agency,Broker,Partner}CompanyInvitationEntityGetById(CompanyId)company-invitation.md
INV-CI7TargetRoleId — Active/системная agent/внутренняя роль компанииCompanyInvitationEntityinvite/accept валидацияcompany-invitation.md
INV-AM1≤ 1 Active членство на пару (AgentUserId,CompanyId); несколько активных по разным агентствам допускаются (ADR-0070)AgentMembershipEntityunique partial + Any(spec) по пареagent-membership.md
INV-AM2Leave/terminate только ставит Status=Left+LeftAt+LeaveReason (без удаления/перезаписи)AgentMembershipEntityметод завершения членстваagent-membership.md
INV-AM3Status=Left ⇔ LeftAt != null ∧ LeaveReason != null, LeftAt > JoinedAtAgentMembershipEntityguard завершенияagent-membership.md
INV-AM4AgentUserId — учётка UserType = Agent (инвестор входит через become-agent)AgentMembershipEntityjoin-handleragent-membership.md
INV-AM5CompanyId.Type ∈ {Agency,Broker,Partner} и ActiveAgentMembershipEntityGetById(CompanyId)agent-membership.md
INV-AM6Зеркало UserEntity.CompanyId = выбранное активное агентство (ActiveCompanyId, ADR-0070); scope роли agentOrganization при ≥1 Active, Platform при нуле; завершение выбранного сбрасывает зеркалоAgentMembershipEntityjoin/leave/terminate (мульти-агрегатная транзакция)agent-membership.md

profile

IDИнвариантСущностьГде проверяетсяДокумент
INV-VRF-1≤ 1 заявка в Draft/Submitted/InReview на OwnerUserIdVerificationRequestEntityunique partial + FindByverification-request.md
INV-VRF-2Submit требует ФИО+ДР+активный паспорт + ≥ 1 сканVerificationRequestEntityметод Submitverification-request.md
INV-VRF-3PersonId — активная персона Relation = SelfVerificationRequestEntityhandler созданияverification-request.md
INV-VRF-4Возраст ≥ 18 на момент SubmitVerificationRequestEntityметод Submitverification-request.md
INV-VRF-5Заявку создаёт только UserType = Investor c permissionVerificationRequestEntityguardverification-request.md
INV-VRF-6TakeInReview только из Submitted; гонка — RowVersionVerificationRequestEntityметод + optimisticverification-request.md
INV-VRF-7После Submit снапшот иммутабелен (доработка — RevokeDraft)VerificationRequestEntityметоды агрегатаverification-request.md
INV-VRF-8Approve/Reject — только ReviewerUserId; Reject требует причинуVerificationRequestEntityметоды решенияverification-request.md
INV-VRF-9Новая заявка запрещена при VerificationStatus = VerifiedVerificationRequestEntityhandler созданияverification-request.md
INV-RQT-1Персона и документы состава — того же OwnerUserIdRequisiteTemplateEntityhandler + сверкаrequisite-template.md
INV-RQT-2IdentityDocumentId — паспортный тип; InnDocumentId — InnRequisiteTemplateEntityhandlerrequisite-template.md
INV-RQT-3Все ссылки состава — Status = ActiveRequisiteTemplateEntityhandlerrequisite-template.md
INV-RQT-4Имя шаблона уникально среди активных владельца (ci)RequisiteTemplateEntityunique partial + FindByrequisite-template.md
INV-RQT-5≤ 1 IsDefault среди активных; SetDefault снимает прежнийRequisiteTemplateEntityunique partial + методrequisite-template.md
INV-RQT-6Управление шаблонами — только UserType = InvestorRequisiteTemplateEntityguardrequisite-template.md
INV-RQT-7≤ 20 активных шаблонов на владельцаRequisiteTemplateEntityhandler (Filter(count))requisite-template.md
INV-RQT-8Снапшот для CRM — только по Status = ActiveRequisiteTemplateEntityinternal APIrequisite-template.md
INV-PRF-1Событие identity применяется только при Version > ReplicaVersionProfileEntityconsumerprofile.md
INV-PRF-2При ContactsManagedBy = TalentSync self-операции запрещеныProfileEntityguard EnsureSelfServiceAllowedprofile.md
INV-PRF-3Self-операции только при Status = ActiveProfileEntityтот же guardprofile.md
INV-PRF-4Персоны/документы/шаблоны/верификация — только InvestorProfileEntityguard EnsureInvestorFeaturesprofile.md
INV-PRF-5DefaultRequisiteTemplateId — активный шаблон владельца с IsDefaultProfileEntityметод SetDefaultTemplateprofile.md
INV-PRF-6VerificationStatus меняется только событиями заявкиProfileEntitynotification-handlerprofile.md
INV-PRS-1≤ 1 персона Relation = Self Active на OwnerUserIdPersonEntityunique partial + фабрикаperson.md
INV-PRS-2Персоны создаёт/меняет только UserType = InvestorPersonEntityguardperson.md
INV-PRS-3При ContactsManagedBy = TalentSync правки запрещеныPersonEntityguardperson.md
INV-PRS-4Архив запрещён при активных шаблонах с PersonIdPersonEntityметод Archive + Filterperson.md
INV-PRS-5Архив запрещён при активной верификации персоныPersonEntityметод Archive + Filterperson.md
INV-PRS-6Персона Self не архивируетсяPersonEntityметод Archiveperson.md
INV-PRS-7≤ 20 активных персон на аккаунтPersonEntityhandler (Filter(count))person.md
INV-PRS-8Все cipher-поля записи — один KeyVersionPersonEntityметоды шифрованияperson.md
INV-DOC-1PayloadCipher расшифровывается строго в модель по TypePersonDocumentEntityAddDocument/UpdateDocumentperson-document.md
INV-DOC-2CountryId согласован с типом и payloadPersonDocumentEntityAddDocument/UpdateDocumentperson-document.md
INV-DOC-3Custom — Title вводится; прочие — генерируетсяPersonDocumentEntityAddDocumentperson-document.md
INV-DOC-4Активный дубль (NumberHash+PersonId+Type) запрещёнPersonDocumentEntityunique partial + FindByperson-document.md
INV-DOC-5≤ 10 сканов, ≤ 20 МБ, ContentType из белого спискаPersonDocumentEntityFluentValidationperson-document.md
INV-DOC-6Архив запрещён, пока документ в активном шаблонеPersonDocumentEntityArchiveDocument + Filterperson-document.md
INV-DOC-7Type иммутабелен (смена = новый документ)PersonDocumentEntityнет setterperson-document.md
INV-DOC-8≤ 20 активных документов на персонуPersonDocumentEntityAddDocumentperson-document.md

chessboard

IDИнвариантСущностьГде проверяетсяДокумент
INV-CHS-001Переходы Status по матрице; Free→Reserved→Booked→Sold — только события CRM/jobPropertyObjectEntityметоды переходов (guard)property-object.md
INV-CHS-002ReservedUntilReserved, BookedUntilBooked; иначе nullPropertyObjectEntityметоды переходовproperty-object.md
INV-CHS-003Release/Book/MarkSold только при DealId == HoldDealIdPropertyObjectEntityconsumer + методproperty-object.md
INV-CHS-004TenantId == Project.TenantId == Building.TenantIdPropertyObjectEntityконструкторproperty-object.md
INV-CHS-005Building.ProjectId == ProjectIdPropertyObjectEntityhandler созданияproperty-object.md
INV-CHS-006Number уникален среди живых объектов зданияPropertyObjectEntityunique + FindByproperty-object.md
INV-CHS-007Layout.ProjectId == ProjectId при привязкеPropertyObjectEntityhandlerproperty-object.md
INV-CHS-008Смена Price запрещена при Sold; при hold — только Developers APIPropertyObjectEntityметод ChangePriceproperty-object.md
INV-CHS-009Hide() только из FreePropertyObjectEntityметод Hideproperty-object.md
INV-PRJ-1TenantId == DeveloperIdProjectEntityконструкторproject.md
INV-PRJ-2DeveloperId существует и не удалёнProjectEntityGetById(DeveloperId)project.md
INV-PRJ-3Publish() требует заполненные поля и Developer.PublishedProjectEntityметод Publishproject.md
INV-PRJ-4Для ApartHotel/Hotel публикация требует HotelierIdProjectEntityметод Publishproject.md
INV-PRJ-5Синк применяется только при Version > ReplicaVersionProjectEntityconsumerproject.md
INV-PRJ-6Все объекты проекта — Currency = Project.CurrencyProjectEntityвалидатор мутацийproject.md
INV-PRJ-7Все банки в Financing существуют (GetExistIds)ProjectEntityhandler Financingproject.md
INV-PRJ-8Архив ⇔ DeletedAt; запрещён при hold-объектахProjectEntityметод Archive + Anyproject.md
INV-PRJ-9Реплика-поля — только синком, PIN-поля — только APIProjectEntityраздельные методыproject.md
INV-BLD-1TenantId == Project.TenantIdBuildingEntityконструкторbuilding.md
INV-BLD-2Name уникально среди живых зданий проектаBuildingEntityunique + FindBybuilding.md
INV-BLD-31 ≤ Floor ≤ FloorsTotal; нельзя опустить ниже занятогоBuildingEntityметод UpdateFloors + Anybuilding.md
INV-BLD-4Publish() требует Project ∈ {Draft,Published} + поляBuildingEntityметод Publishbuilding.md
INV-BLD-5Синк только при Version > ReplicaVersionBuildingEntityconsumerbuilding.md
INV-BLD-6Delivered ⇔ DeliveredAt != null и ReadinessPercent = 100BuildingEntityметод MarkDeliveredbuilding.md
INV-BLD-7Реплика-поля — синком, PIN-поля — APIBuildingEntityраздельные методыbuilding.md
INV-LAY-1TenantId == Project.TenantIdLayoutEntityконструкторlayout.md
INV-LAY-2Name уникально среди живых планировок проектаLayoutEntityunique + FindBylayout.md
INV-LAY-3LayoutGroup.ProjectId == ProjectId при привязкеLayoutEntityhandlerlayout.md
INV-LAY-4Синк только при Version > ReplicaVersionLayoutEntityconsumerlayout.md
INV-LAY-5Архив запрещён при живых объектах с LayoutIdLayoutEntityметод Archive + Anylayout.md
INV-LAY-6Реплика-поля — синком, PIN-поля — APILayoutEntityраздельные методыlayout.md
INV-LGR-1TenantId == Project.TenantIdLayoutGroupEntityконструкторlayout-group.md
INV-LGR-2Name уникально среди живых групп проектаLayoutGroupEntityunique + FindBylayout-group.md
INV-LGR-3Архив запрещён при живых планировках группыLayoutGroupEntityметод Archive + Anylayout-group.md
INV-LGR-4Синк только при Version > ReplicaVersionLayoutGroupEntityconsumerlayout-group.md
INV-DEV-1Id = CompanyEntity.Id с Type = Developer в identityDeveloperEntityconsumer/admindeveloper.md
INV-DEV-2ExternalTalentDeveloperId уникаленDeveloperEntityunique + FindBydeveloper.md
INV-DEV-3Talent-поля синком, PIN-поля APIDeveloperEntityраздельные методыdeveloper.md
INV-DEV-4Синк только при Version > ReplicaVersionDeveloperEntityconsumerdeveloper.md
INV-DEV-5Publish() требует Name,LogoFileId,DescriptionDeveloperEntityметод Publishdeveloper.md
INV-DEV-6Hidden скрывает public-проекции всех проектовDeveloperEntityquery-слой + событиеdeveloper.md
INV-HTL-1Name уникально среди живых отельеровHotelierEntityunique + FindByhotelier.md
INV-HTL-2Архив запрещён при живых проектах отельераHotelierEntityhandler + Anyhotelier.md
INV-BNK-1Name уникально среди живых банковBankEntityunique + FindBybank.md
INV-BNK-2Архив не каскадирует; ссылки остаются валиднымиBankEntityquery-слой (фильтр)bank.md

invest-analytics

IDИнвариантСущностьГде проверяетсяДокумент
INV-MC1В model_config ровно одна строка (singleton)EconomicModelConfigunique-индекс + Upserteconomic-model-config.md
INV-MC2Диапазоны коэффициентов (доли 0..1, темпы, горизонт)EconomicModelConfigFluentValidation + checkeconomic-model-config.md
INV-MC3Все значения — конечные числа (нет NaN/Inf)EconomicModelConfigтип numeric + валидаторeconomic-model-config.md
INV-MC4Каждое сохранение инкрементирует VersionEconomicModelConfighandlereconomic-model-config.md
INV-LT1GroupId существует как группа планировок в chessboardPropertyObjectTypeConfighandler upsertproperty-object-type-config.md
INV-LT2Заполненные коэффициенты — в диапазонах INV-MC2PropertyObjectTypeConfigFluentValidation + checkproperty-object-type-config.md
INV-LT3Значения — конечные числаPropertyObjectTypeConfignumeric + валидаторproperty-object-type-config.md
INV-LT4Name непустое, ≤ 128PropertyObjectTypeConfigвалидаторproperty-object-type-config.md
INV-MO1≤ 1 строка на (GroupId,Year,Month)PropertyObjectTypeMonthOverridecomposite PKproperty-object-type-month-override.md
INV-MO2GroupId имеет строку PropertyObjectTypeConfigPropertyObjectTypeMonthOverrideFK + GetByIdproperty-object-type-month-override.md
INV-MO3Диапазоны (месяц 1–12, год, доли, налоги)PropertyObjectTypeMonthOverrideFluentValidation + checkproperty-object-type-month-override.md
INV-MO4Строка содержит ≥ 1 not-null оверрайдPropertyObjectTypeMonthOverridehandlerproperty-object-type-month-override.md
INV-MO5В payload replace-all нет дублей (Year,Month)PropertyObjectTypeMonthOverrideFluentValidationproperty-object-type-month-override.md
INV-MO6Replace-all — атомарно (delete+addRange+touch)PropertyObjectTypeMonthOverridehandler (SaveChanges)property-object-type-month-override.md
INV-CALC-1Ни одно поле результата не NaN/Inf (делители проверены)расчётные моделифункции + guardinvestor-series-models.md
INV-CALC-2Абсолютные оверрайды применяются только при > 0расчётные моделиправило positiveOrinvestor-series-models.md
INV-CALC-3До DeliveryTimeRent=Expenses=Ebitda=0расчётные моделифлаг inOperationinvestor-series-models.md
INV-CALC-4Months.Count == HorizonMonths + 1расчётные моделиконструктор результатаinvestor-series-models.md
INV-CALC-5Ставки налогов: оверрайд месяца → ставка режимарасчётные моделиформулы ставокinvestor-series-models.md
INV-CALC-6Результат детерминирован по входурасчётные моделичистые функцииinvestor-series-models.md

crm

IDИнвариантСущностьГде проверяетсяДокумент
INV-CLI-1Телефон уникален в рамках агентства (живые карточки)ClientEntityunique + FindByclient.md
INV-CLI-2ActiveLeadId указывает на Active-лид этого клиентаClientEntityметоды агрегатаclient.md
INV-CLI-3Зарегистрированный пользователь ≤ 1 раз на агентствоClientEntityunique + FindByclient.md
INV-CLI-4Архив только без активных лидов/фиксаций/сделокClientEntityметод Archive + Anyclient.md
INV-LEAD-2События/фиксации/сделки — только у Active-лидаLeadEntityметоды агрегатаlead.md
INV-LEAD-3Продление: ExpiresAt = max(now,ExpiresAt)+1 мес, ExtensionsCount++; продлевать можно Active/Expired (реактивация с повторной проверкой INV-CRM-001)LeadEntityметод Extendlead.md
INV-LEAD-4Expired ставит только worker при ExpiresAt < now и Active; активные сделки не трогаютсяLeadEntityjob lead-expirationlead.md
INV-LEAD-5Wishes редактирует участник агентской роли лида / Менеджер ПИН; фиксация делает snapshot-overrideLeadEntityметод UpdateWisheslead.md
INV-LEAD-6Иммутабельны (set-once) только Origin: TenantId(=AgencyCompanyId), OriginAgentUserId — не пересчитываются при смене агентства/передаче ведения (ADR-0071); ExecutorAgentUserId мутабеленLeadEntityфабрика (private-set)lead.md
INV-LEAD-7Среди активных участников: ровно один Executor, один InvestorPrimary; прочие роли по составу; UserId уникаленLeadEntityunique partial + методыlead.md
INV-LEAD-8Лид нельзя оставить без активного Executor; смена исполнителя — только атомарной передачей ведения (производные пересчитываются)LeadEntityметоды состава/передачиlead.md
INV-FIX-2Создание — из Active-лида агентом-владельцем (или Менеджером ПИН)FixationRequestEntityвалидация + GetByIdfixation-request.md
INV-FIX-3Решение — из Pending менеджером компании TenantIdFixationRequestEntityметоды Approve/Rejectfixation-request.md
INV-FIX-4Отмена — из Pending агентом-инициатором (или руководителем агентства/Менеджером ПИН)FixationRequestEntityметод Cancelfixation-request.md
INV-FIX-5Rejected требует причину; FixedUnique только при NoDuplicates (иначе FixedNonUnique/forceUnique с audit)FixationRequestEntitycheck + Approvefixation-request.md
INV-FIX-6Одобрение — только авторизация, не сделка: Pending → FixedUnique/FixedNonUnique подтверждает право агента вести клиента по объектам застройщика; сделка при одобрении не создаётся, заявка ссылки на сделку не хранит. Одна одобренная фиксация авторизует много сделок пары «клиент × застройщик» (связь 1:N со стороны сделки — DealEntity.FixationRequestId)FixationRequestEntityhandler одобренияfixation-request.md
INV-FIX-7Иммутабельна только атрибуция OriginAgencyCompanyId/OriginAgentUserId (set-once, ADR-0071); текущий ExecutorAgentUserId мутабелен (reassign полномочными)FixationRequestEntityфабрика + reassignfixation-request.md
INV-STEP-1Каждый этап привязан к системной группе; Group иммутабеленPipelineStepEntityконструктор/Updatepipeline-step.md
INV-STEP-2Живые этапы уникальны по имени (ci) и Order в TenantIdPipelineStepEntityunique + Anypipeline-step.md
INV-STEP-3На каждую группу — ≥ 1 живой этапPipelineStepEntityархив: Count > 1pipeline-step.md
INV-STEP-4Архив этапа с сделками требует этап-приёмникPipelineStepEntityArchiveStep(target)pipeline-step.md
INV-STEP-5Системные группы не редактируются через APIPipelineStepGroupнет command-операцийpipeline-step.md
INV-DEAL-1Сделка — из Active-лида; PropertyObjectId обязателен; TenantId=застройщик объекта (set-once). Ветка по гейту вовлечения застройщика: создание сразу в Application требует PaymentMethod; создание в Draft (гейт не выполнен) — PaymentMethod необязателен (требование откладывается до Draft → Application)DealEntitycommand создания + GetByIddeal.md
INV-DEAL-2Начальный состав по статусу создания: DraftExecutor+InvestorPrimary+auto-PinManager без SellManager (застройщик не вовлечён); Application — те же + SellManager. SellManager появляется только с переходом в ApplicationDealEntityфабрика Deal.Createdeal.md
INV-DEAL-3Среди активных: один Executor, один InvestorPrimary, ≤1 PinManager; UserId уникален; произвольно CoAgent/Assistant/Lawyer/InvestorSecondary; без активного Executor нельзя — смена только атомарной передачей веденияDealEntityметоды Member + uniquedeal.md
INV-DEAL-4ChangePropertyObject — в Application/Booking (при Booking — с отменой брони), тот же TenantId, Price перечитываетсяDealEntityметод ChangePropertyObjectdeal.md
INV-DEAL-5MoveToStep: этап TenantId, не архивный; группа Cancelled требует CancelReasonDealEntityметод MoveToStepdeal.md
INV-DEAL-6С Sale обязательны ContractType+PaymentMethod; InstallmentInstallmentPresetIdDealEntityguard StartSaledeal.md
INV-DEAL-7Даты оформления (договоры→эскроу→Росреестр→завершение) — менеджером TenantId строго по порядкуDealEntityметоды этаповdeal.md
INV-DEAL-8Cancelled из нетерминального; активная бронь отменяется тем же изменениемDealEntityметод Canceldeal.md
INV-DEAL-9Терминальные Completed/Cancelled не редактируютсяDealEntityкомандные методыdeal.md
INV-DEAL-10Иммутабельны только Origin-поля OriginAgencyCompanyId/OriginAgentUserId (set-once, ADR-0071 — основание комиссии/воронка); Executor/Responsible/PrimaryInvestor — мутабельные производные составаDealEntityфабрика + guard правки Origindeal.md
INV-DEAL-11Гейт Draft → Application (вовлечение застройщика): переход только при одновременно (а) LeadBookingIntentEntity.ConfirmedByInvestor = true (иначе CRM_DEAL_INTENT_NOT_CONFIRMED), (б) одобренная фиксация пары «инвестор × застройщик» (иначе CRM_DEAL_FIXATION_REQUIRED), (в) задан PaymentMethod (иначе CRM_DEAL_PAYMENT_METHOD_REQUIRED); при выполнении добавляется SellManager, проставляется IntentConfirmedAt. Обратного перехода нетDealEntityметод EngageDeveloper (engage-developer)deal.md
INV-INTENT-1На пару (LeadId,PropertyObjectId) — не более одного намерения Active; снятых (Cancelled) — сколько угодноLeadBookingIntentEntityunique partial ux_lead_booking_intents_active_objectlead.md
INV-INTENT-2Добавление/подтверждение/снятие намерения — только у лида Status = ActiveLeadBookingIntentEntityкоманды над намерениемlead.md
INV-INTENT-3При создании: InvestorAddedConfirmedByInvestor = true, ConfirmedAt = CreatedAt; AgentProposedConfirmedByInvestor = false, ConfirmedAt = nullLeadBookingIntentEntityфабрика намеренияlead.md
INV-INTENT-4Подтвердить намерение (ConfirmedByInvestor: false → true, ConfirmedAt = now) может только участник-инвестор пары; повторное подтверждение — no-opLeadBookingIntentEntityметод подтверждения (Flow B)lead.md
INV-INTENT-5PropertyObjectId/ProjectId/DeveloperCompanyId/Source — set-once снапшот из объекта, иммутабельныLeadBookingIntentEntityфабрика намеренияlead.md
INV-INTENT-6DealId set-once; связь активного намерения и сделки — 1:1LeadBookingIntentEntityсвязывание намерения со сделкойlead.md
INV-BKG-2Создание брони — у сделки ∈ {Application,Booking}; сделка→BookingBookingEntitycommand + OnBookingCreatedbooking.md
INV-BKG-3ConfirmBooking: платная — после Paid; бесплатная — из ReservedBookingEntityметод ConfirmBookingbooking.md
INV-BKG-4ConfirmPayment — менеджер TenantId, из AwaitingPayment, при чекеBookingEntityметод ConfirmPaymentbooking.md
INV-BKG-5Истечение — только worker (Reserve/Booking таймеры)BookingEntityjob booking-expirationbooking.md
INV-BKG-6Extend — только Booked менеджером TenantIdBookingEntityметод Extendbooking.md
INV-BKG-7Complete — из Booked, одной транзакцией с Deal.StartSaleBookingEntityhandler StartSalebooking.md
INV-BKG-8Терминальные статусы иммутабельныBookingEntityкомандные методыbooking.md
INV-BPR-1IsPaid ⇒ Price > 0; !IsPaid ⇒ Price = nullBookingProgramEntitycheck + FluentValidationbooking-program.md
INV-BPR-2≤ 1 живой default на застройщика; SetDefault снимает прежнийBookingProgramEntityunique partial + SetDefaultbooking-program.md
INV-BPR-3Имя уникально в TenantId среди живыхBookingProgramEntityuniquebooking-program.md
INV-BPR-4Правка условий не затрагивает существующие брони (snapshot)BookingProgramEntityархитектурное правилоbooking-program.md
INV-BPR-5≥ 1 живая программа у застройщика (SEED default)BookingProgramEntityархив: Count > 1booking-program.md
INV-PPR-1Один живой агрегат программы покупки на застройщикаPurchaseProgramEntityuniquepurchase-program.md
INV-PPR-2Проценты 0–100; сроки > 0PurchaseProgramEntityFluentValidation + checkpurchase-program.md
INV-PPR-3≤ 1 remainder в MilestonePayments, он последнийInstallmentPresetEntityвалидатор пресетаpurchase-program.md
INV-PPR-4График пресета погашает 100% discountedPriceInstallmentPresetEntityвалидатор пресетаpurchase-program.md
INV-PPR-5Пресет в активных сделках нельзя архивироватьInstallmentPresetEntityархив: Any(deals)purchase-program.md
INV-DOC-1Загружать тип может только сторона-владелец типаDealDocumentEntitycommand загрузкиdeal-document.md
INV-DOC-2Accepted/Rejected — только проверяющий; Rejected требует причинуDealDocumentEntityметод Review + checkdeal-document.md
INV-DOC-3С Uploaded обязательны FileId/имя/тип/размерDealDocumentEntitycheck + FluentValidationdeal-document.md
INV-DOC-4Повтор после Rejected — новая запись (append-only)DealDocumentEntitycommand загрузкиdeal-document.md
INV-DOC-5Документы только у сделки в нетерминальном статусеDealDocumentEntityGetById(DealId) + guarddeal-document.md
INV-DOC-6Смена статуса → уведомление + таймлайн той же транзакциейDealDocumentEntityhandler Review/Uploaddeal-document.md
INV-TLE-1Append-only: контент события иммутабеленDealTimelineEventEntityEF-конфигурацияdeal-timeline-event.md
INV-TLE-2Запись — в одной транзакции с породившей мутациейDealTimelineEventEntityhandler-ы CRMdeal-timeline-event.md
INV-TLE-3InProgress требует DueAt или парное закрытиеDealTimelineEventEntityфабрики событийdeal-timeline-event.md
INV-TLE-4Overdue ставит только worker timeline-overdueDealTimelineEventEntitybackground jobdeal-timeline-event.md
INV-TLE-5ManualNote — только PinManager/Служба заботыDealTimelineEventEntityвалидация actordeal-timeline-event.md

referral

IDИнвариантСущностьГде проверяетсяДокумент
INV-REF-001≤ 1 Active-связь на ReferredUserId; автоперепривязка запрещенаReferralBondEntityunique partial + FindByreferral-bond.md
INV-REF-002Лид создаётся только если владелец ссылки — Agent/DeveloperManagerпроцесс привязкиhandlerattach-rules.md
INV-REF-003«Имя заполнено И регистрация ≥ 4 ч» ⇒ связь не создаётсяпроцесс привязкиматрица R3attach-rules.md
INV-REF-004Владелец ссылки не может стать своим рефераломпроцесс привязкиguard G3attach-rules.md
INV-REF-005Привязка и лид — только Type = Investorпроцесс привязкиguard G2attach-rules.md
INV-REF-B2ReferredUserId != ReferrerUserIdReferralBondEntityguard Createreferral-bond.md
INV-REF-B3Основные поля связи иммутабельны после созданияReferralBondEntityнет сеттеровreferral-bond.md
INV-REF-B4Replaced — только с одновременным созданием заменыReferralBondEntityметод Replacereferral-bond.md
INV-REF-B5Revoked ⇒ RevokeReason непустойReferralBondEntitycheck + Revokereferral-bond.md
INV-REF-B6Replaced/Revoked — терминальныеReferralBondEntityguard методовreferral-bond.md

investor-owner

IDИнвариантСущностьГде проверяетсяДокумент
INV-OWN-001InvestorUserId = Id при LinkKind = InvestorCreated (Id = OwnerId)OwnerMappingEntityметод LinkToCreatedInvestorowner-mapping.md

favorites

IDИнвариантСущностьГде проверяетсяДокумент
INV-FAV-001Заполнен ровно один из UserId/AnonymousIdFavoriteфабрика + DB checkfavorite.md
INV-FAV-002Одна запись на «владелец + объект» (идемпотентность)FavoriteFindBy + uniquefavorite.md
INV-FAV-003Note — null или 1..4000 после trimFavoriteFluentValidation + методfavorite.md
INV-FAV-004PropertyObjectId — валидный Guid (без sync-проверки)Favoriteвалидатор форматаfavorite.md
INV-FAV-005Читать/менять запись может только владелецFavoriterecord-level фильтрfavorite.md
INV-FAV-006Смена владельца — только слиянием/импортомFavoriteнет мутатора; merge-handlerfavorite.md
INV-FAV-101≤ 1 merge-операция на (UserId,AnonymousId), идемпотентнаFavoritesMergeOperationFindBy + uniquemerge-strategy.md
INV-FAV-102Сессия AnonymousId связана именно с этим UserIdFavoritesMergeOperationinternal-запрос user-activitymerge-strategy.md
INV-FAV-103strategy ∈ FavoritesMergeStrategyFavoritesMergeOperationFluentValidationmerge-strategy.md
INV-FAV-104Слияние — одна транзакция (нет частичного)FavoritesMergeOperationSaveChangesmerge-strategy.md
INV-FAV-105Слияние не читает/не трогает чужие записиFavoritesMergeOperationпредикаты Filtermerge-strategy.md

selections

IDИнвариантСущностьГде проверяетсяДокумент
INV-SEL-002Мутации — только OwnerUserId; чтение по PublicCode — любойSelectionrecord-level фильтрselection.md
INV-SEL-003Объект входит в подборку ≤ 1 разаSelectionметод + uniqueselection.md
INV-SEL-004≤ 200 элементовSelectionметод + FluentValidationselection.md
INV-SEL-005PublicCode генерируется при создании и не меняетсяSelectionнет мутатораselection.md
INV-SEL-006SortOrder — плотная последовательность 0..N-1Selectionметод SetItemsselection.md
INV-SEL-007≤ 100 живых подборок на владельцаSelectioncreate-handler Countselection.md
INV-SEL-008PropertyObjectId без sync-проверкиSelectionвалидатор форматаselection.md
INV-SEL-009DeletedAt — не читается/не редактируется публичноSelectionguard всех handlerselection.md
INV-SEL-010Копия из избранного отвязана (без синка)Selectioncreate-from-favoritesselection.md
INV-SEL-001Content snapshot-а иммутабелен (замена блока целиком)SelectionSnapshotнет мутатора Contentselection-snapshot.md
INV-SNP-002Snapshot — только из живой подборкиSelectionSnapshotcreate-handler + guardselection-snapshot.md
INV-SNP-003Создание/detach — только m2m-актор newsSelectionSnapshotauthorization policyselection-snapshot.md
INV-SNP-004В слепке 1..200 элементовSelectionSnapshotcreate-handlerselection-snapshot.md
INV-SNP-005Active → Detached однократен (идемпотентно)SelectionSnapshotметод Detach + RowVersionselection-snapshot.md
INV-SNP-006Snapshot не удаляется (история постов)SelectionSnapshotнет deleteselection-snapshot.md

user-activity

IDИнвариантСущностьГде проверяетсяДокумент
INV-SES-001≤ 1 Active-сессия на AnonymousIdSessionstart-handler + uniquesession.md
INV-SES-002UserId ставится один раз; вход другого — форс-закрытиеSessionlink-handler / consumersession.md
INV-SES-003Heartbeat обновляет только Active; иначе новая сессияSessionheartbeat-handlersession.md
INV-SES-004Все timestamp ставит серверSessionвсе handlerssession.md
INV-SES-005Active→Inactive — worker/link: Duration,EndedAt, закрытие просмотровSessionworker / link-handlersession.md
INV-SES-006Inactive-сессия неизменяемаSessionworker, handlerssession.md
INV-LV-001≤ 1 Active-просмотр на (SessionId,PropertyObjectId)PropertyObjectViewopen-handler + uniqueproperty-object-view.md
INV-LV-002Просмотр только в Active-сессии (иначе ensure-старт)PropertyObjectViewopen-handlerproperty-object-view.md
INV-LV-003Heartbeat только Active; иначе новая записьPropertyObjectViewheartbeat-handlerproperty-object-view.md
INV-LV-004Active→Inactive — worker: Duration,EndedAt, outboxPropertyObjectViewworker activity-closerproperty-object-view.md
INV-LV-005FilterSnapshot фиксируется при созданииPropertyObjectViewнет мутатораproperty-object-view.md
INV-LV-006PropertyObjectId — Guid без sync-проверкиPropertyObjectViewвалидатор форматаproperty-object-view.md
INV-LV-007У Inactive-сессии нет Active-просмотровPropertyObjectViewworker / link-handlerproperty-object-view.md
INV-LV-008Inactive-запись неизменяемаPropertyObjectViewworkerproperty-object-view.md
INV-LVS-001Ровно одна строка на PropertyObjectIdPropertyObjectViewStatsPK + Upsertproperty-object-view-stats.md
INV-LVS-002ViewsCount/TotalDuration только растут (атомарный UPDATE)PropertyObjectViewStatsконсюмер + checkproperty-object-view-stats.md
INV-LVS-003Одно событие инкрементит ≤ 1 раза (PropertyObjectViewId)PropertyObjectViewStatsконсюмер + IdempotencyKeyproperty-object-view-stats.md
INV-LVS-004LastViewedAt = greatest(…) (вне порядка)PropertyObjectViewStatsконсюмер (SQL greatest)property-object-view-stats.md
INV-LVS-005AvgDuration не хранится — считается на чтенииPropertyObjectViewStatsquery-handlerproperty-object-view-stats.md
INV-LVS-006Пишет только консюмер (нет public-мутаций)PropertyObjectViewStatsнет command-APIproperty-object-view-stats.md
INV-ACT-001Событие append-only (retention батчами)ActionEventнет update/delete-APIaction-event.md
INV-ACT-002Обязательные поля подтипа заполненыActionEventконструкторы подтипов + checkaction-event.md
INV-ACT-003Каждое событие привязано к сессии (ensure)ActionEventhandlers/consumersaction-event.md
INV-ACT-004Consumer-записи уникальны по SourceMessageIdActionEventFindBy + uniqueaction-event.md
INV-ACT-005OccurredAt для track ставит серверActionEventhandlers/consumersaction-event.md
INV-ACT-006ApplyFilter — ≥ 1 фильтр-полеActionEventFluentValidationaction-event.md
INV-ACT-007SearchQuery 1..512; диапазоны Min ≤ MaxActionEventFluentValidationaction-event.md

news

IDИнвариантСущностьГде проверяетсяДокумент
INV-NWS-001Suffix уникален среди живых (kebab-case)Postunique + validatorpost.md
INV-NWS-002Publish только при AuthorType ∈ AllowedAuthorTypes + permissionPostcreate/publish-handlerpost.md
INV-NWS-003AuthorCompanyId — только менеджеры; ProjectRefs — DeveloperManagerPostvalidator + методpost.md
INV-NWS-004Publish требует Title + ≥ 1 блок + Active-snapshot embedPostметод Publish()post.md
INV-NWS-005Каждая публикация пишет новый PostVersionPostPublish() + транзакцияpost.md
INV-NWS-006Комментарий — только Published + CommentsEnabledPostComment.Create guardpost.md
INV-NWS-007Мутации — автор/менеджер компании/permissionPostrecord-level + handlerpost.md
INV-NWS-008SearchText/ES-проекция пересчитываются при правкахPostобработчик события + индексаторpost.md
INV-CAT-001Slug уникален, неизменяемPostCategoryunique + FindBypost-category.md
INV-CAT-002Публикация только если UserType ∈ AllowedAuthorTypesPostCategoryhandler публикацииpost-category.md
INV-CAT-003При наличии постов — только Archived, не удалениеPostCategoryhandler + Anypost-category.md
INV-CAT-004CRUD категорий — только admin-акторPostCategoryauthorization pipelinepost-category.md
INV-REP-001Событие применяется только при Version > SourceVersionAuthorReplica/CompanyReplicaconsumeridentity-replicas.md
INV-REP-002Author.Blocked → подпись помечается (посты остаются)AuthorReplicaquery-слой выдачиidentity-replicas.md
INV-REP-003Company.Suspended → посты вне публичных лентCompanyReplicaquery-слой публичных лентidentity-replicas.md
INV-REP-004Upsert по Id идемпотентенAuthorReplica/CompanyReplicaconsumer + IdempotencyKeyidentity-replicas.md
INV-CMT-001Создание — Published + CommentsEnabledCommentvalidator + GetByIdcomment.md
INV-CMT-002ParentComment.PostId == PostIdCommentметод Replycomment.md
INV-CMT-003Depth ≤ 6Commentcheck + Replycomment.md
INV-CMT-004Delete — автор; hide — permission moderateCommentauthorizationcomment.md
INV-CMT-005CommentsCount инкремент/декремент (reconcile-job)Commentобработчик событияcomment.md
INV-CMT-006Из deleted/hidden обратно в visible нельзяCommentguard статусаcomment.md
INV-SUB-001≤ 1 живая подписка на цельAuthorSubscriptionpartial unique + FindByauthor-subscription.md
INV-SUB-002Ровно одно из TargetUserId/TargetCompanyIdAuthorSubscriptioncheck + validatorauthor-subscription.md
INV-SUB-003Нельзя подписаться на себяAuthorSubscriptionvalidatorauthor-subscription.md
INV-SUB-004Управляет строкой только сам подписчик (JWT)AuthorSubscriptionactor-контекстauthor-subscription.md

messenger

IDИнвариантСущностьГде проверяетсяДокумент
INV-CHAT-1Direct уникален на неупорядоченную пару (DirectKey); OpenDirect идемпотентенChatEntityOpenDirect + uniquechat.md
INV-CHAT-2Deal-чат уникален на DealId; провизия из deal-created идемпотентнаChatEntityконсюмер + uniquechat.md
INV-CHAT-2LLead-чат уникален на LeadId; провизия идемпотентнаChatEntityконсюмер + uniquechat.md
INV-CHAT-3Писать = нет блокировки сторонами; звонить = accepted ContactChatEntitypolicy PostMessagechat.md
INV-CHAT-4Доступ = активный Participant (UserId = actor); tenant-фильтра нетChatEntityguard операцийchat.md
INV-CHAT-5При посте атомарно: Seq++, обновление Last*ChatEntityRegisterPosted под xminchat.md
INV-CHAT-6Состав Lead/Deal-чата зеркалит активных LeadParticipant/DealMember (ADR-0071); пользовательских ручек состава нетChatEntityконсюмеры crmchat.md
INV-CHAT-7Direct — ровно 2 активных Member; Lead/Deal — ≥ 1; число по ролям не ограничено (несколько агентов/инвесторов); ролевой состав валидирует crmChatEntityOpenDirect/синк составаchat.md
INV-CHAT-8Closed-чат: сообщения пользователей запрещены (системные карточки допустимы)ChatEntityguard PostMessagechat.md
INV-PART-1Уникальность активного (ChatId,UserId); повтор = реактивацияParticipantEntityunique + AddParticipantparticipant.md
INV-PART-2LastReadSeq монотонен (max, ≤ LastMessageSeq)ParticipantEntityMarkRead (GREATEST)participant.md
INV-PART-3Состав по типу чата (Direct 2 / Deal = DealMember)ParticipantEntityфабрика/методы Chatparticipant.md
INV-PART-4Удаление участия — soft (RemovedAt), теряет доступParticipantEntityRemoveParticipantparticipant.md
INV-PART-5Read-receipt виден автору (по LastReadSeq)ParticipantEntityпроекция read-receiptparticipant.md
INV-MU-1Snapshot применяется только при Version > IdentityUserVersionMessengerUserEntityversion-guard консюмераmessenger-user.md
INV-MU-2PhoneNumber E.164 и уникаленMessengerUserEntityнормализация + uniquemessenger-user.md
INV-MU-3Поиск отдаёт маску фамилии до Accepted-контактаMessengerUserEntityпроекция результатаmessenger-user.md
INV-MU-4Реплика read-only для пользовательских ручекMessengerUserEntityнет write-хендлеровmessenger-user.md
INV-MSG-1Править/удалять — только автор, пока now ≤ EditableUntil (≤60с)MessageEntityметоды Edit/Deletemessage.md
INV-MSG-2Каждая правка пишет MessageEditEntity (append-only)MessageEntityEdit/Deletemessage.md
INV-MSG-3Seq атомарно из Chat.LastSeqCounter; порядок по SeqMessageEntityRegisterPosted + uniquemessage.md
INV-MSG-4EventCard/системные: SenderUserId = null, не правятсяMessageEntityфабрика + guardmessage.md
INV-MSG-5Видимость EventCard: нет recipient-строк ⇒ всемMessageEntitylist-messages фильтрmessage.md
INV-MSG-6IP/UA обязательны для user-сообщений (Sensitive)MessageEntitycommand + маппингmessage.md
INV-MSG-7Attachment хранит только FileId + метаданныеMessageEntityвалидатор Attachmentmessage.md
INV-MSG-8Идемпотентность карточек из crm (ux по SourceEventId)MessageEntityконсюмер + uniquemessage.md
INV-CE-1Append-only: контент события иммутабеленChatEventEntityEF-конфигурацияchat-event.md
INV-CE-2Запись — в одной транзакции с мутациейChatEventEntityhandler-ы messengerchat-event.md
INV-CE-3Не влияет на Seq/unread (отдельная лента)ChatEventEntityконфигурация лентыchat-event.md
INV-CE-4Членские события пишет консюмер deal-members идемпотентноChatEventEntityKafkaEventConsumerchat-event.md

notifications

IDИнвариантСущностьГде проверяетсяДокумент
INV-NTF-1Fan-out идемпотентен по (SourceMessageId,Recipient,Channel,Kind)NotificationEntityunique + Createnotification.md
INV-NTF-2Создание: Pending,Attempts=0,RenderedBody не пустNotificationEntityфабрика Createnotification.md
INV-NTF-3Канал создаётся только если разрешён настройкамиNotificationEntityconsumer перед Createnotification.md
INV-NTF-4Тихие часы (не Critical) → ScheduledAtNotificationEntityApplyQuietHoursnotification.md
INV-NTF-5Переходы Pending→Sent/Failed; Sent внешнего — терминальноNotificationEntityметоды статусаnotification.md
INV-NTF-6Read только InApp и Recipient = actorNotificationEntityMarkRead(actor)notification.md
INV-NTF-7Retry прекращается при Attempts ≥ MaxAttemptsNotificationEntityretry-workernotification.md
INV-UNS-1Одна запись на UserId; правит владелецUserNotificationSettingsEntityunique + handleruser-notification-settings.md
INV-UNS-2(Kind,Channel) в Preferences уникальныUserNotificationSettingsEntityunique + SetPreferenceuser-notification-settings.md
INV-UNS-3Резолв каналов: явное IsEnabled иначе дефолт; GlobalMute→CriticalUserNotificationSettingsEntityResolveChannelsuser-notification-settings.md
INV-UNS-4Urgency = Critical нельзя отключить (канал сменить можно)UserNotificationSettingsEntityResolveChannelsuser-notification-settings.md
INV-UNS-5Тихие часы — только interruptive-каналамUserNotificationSettingsEntityIsInQuietHoursuser-notification-settings.md
INV-TPL-1Code = "{Kind}:{Locale}" уникален; один Active на (Kind,Locale)NotificationTemplateEntityunique + Publishnotification-template.md
INV-TPL-2Плейсхолдеры тела ⊆ допустимых для KindNotificationTemplateEntitySetChannelBodynotification-template.md
INV-TPL-3Переход в Active требует ≥ 1 канал с IsEnabledNotificationTemplateEntityPublishnotification-template.md
INV-TPL-4IsSystem-шаблон нельзя архивировать/удалитьNotificationTemplateEntityкомандные методыnotification-template.md
INV-TPL-5Резолв: Active (Kind,Locale) → fallback ru → skip каналаNotificationTemplateEntityResolveTemplatenotification-template.md
INV-CHN-1Один ChannelConfigEntity на канал (SEED)ChannelConfigEntityunique + SEEDchannel.md
INV-CHN-2Диспатч только при IsEnabled; иначе skip/pendingChannelConfigEntityconsumer + dispatcherchannel.md
INV-CHN-3MaxBodyLength → усечение с многоточиемChannelConfigEntityрендерchannel.md
INV-CHN-4MaxAttempts/RetryBackoff — порог retry-workerChannelConfigEntityretry-workerchannel.md

support

IDИнвариантСущностьГде проверяетсяДокумент
INV-TKT-1Создание: New, Assignee = null, QueueId по маршрутизацииTicketEntityфабрика + GetById(QueueId)ticket.md
INV-TKT-2Take: только New→InWork, Assignee = actorTicketEntityметод Taketicket.md
INV-TKT-3Assign: супервизор задаёт Assignee в New/InWorkTicketEntityметод Assignticket.md
INV-TKT-4Переходы New→InWork→Resolved→Closed(+реопен); Resolved требует AssigneeTicketEntityметод ChangeStatusticket.md
INV-TKT-5FirstResponseAt — раз, при первом публичном ответеTicketEntityобработчик перепискиticket.md
INV-TKT-6SLA-переходы ставит только workerTicketEntitySLA-workerticket.md
INV-TKT-7Терминальный Closed неизменяем (кроме реопена)TicketEntityкомандные методыticket.md
INV-MSG-1Requester-сообщение обязано PublicTicketMessageEntityAddMessage + validatorticket-message.md
INV-MSG-2Internal только при OperatorTicketMessageEntityAddMessage + validatorticket-message.md
INV-MSG-3Автор — участник тикета либо оператор с permissionTicketMessageEntityAddMessageticket-message.md
INV-MSG-4Нельзя добавить сообщение к Closed без реопенаTicketMessageEntityguard AddMessageticket-message.md
INV-MSG-5Первое публичное сообщение оператора → FirstResponseAtTicketMessageEntityAddMessageticket-message.md
INV-Q-1Code уникален в TenantIdTicketQueueEntityunique + Anyticket-queue.md
INV-Q-2Ровно одна активная IsDefault-очередьTicketQueueEntityunique partial + SetDefaultticket-queue.md
INV-Q-3RoutingCategory уникальна среди активныхTicketQueueEntityunique partial + Anyticket-queue.md
INV-Q-4Архив запрещён при активных тикетах / последней defaultTicketQueueEntityArchiveQueue + Countticket-queue.md
INV-Q-5Маршрутизация: категория → активная очередь, иначе defaultTicketQueueEntityResolveQueue(category)ticket-queue.md
INV-TPL-1Code уникален в TenantId (живые)TicketTemplateEntityunique + проверкаticket-template.md
INV-TPL-2Плейсхолдеры из белого спискаTicketTemplateEntityFluentValidationticket-template.md
INV-TPL-3apply: рендер плейсхолдеров → TicketMessage.Body, UsageCount++TicketTemplateEntityhandler reply-ticketticket-template.md
INV-TPL-4Архивированный шаблон не применяетсяTicketTemplateEntityguard applyticket-template.md
INV-KB-1Slug уникален среди живыхKnowledgeBaseArticleEntityunique + FindByknowledge-base-article.md
INV-KB-2Публикация требует непустые Title/BodyKnowledgeBaseArticleEntityметод Publishknowledge-base-article.md
INV-KB-3Public отдаёт только Published с подходящей AudienceKnowledgeBaseArticleEntityспецификация выдачиknowledge-base-article.md
INV-KB-4ViewsCount растёт только при чтении Published (приблизит.)KnowledgeBaseArticleEntityобработчик чтенияknowledge-base-article.md
INV-ARB-1Переходы Registered→UnderInvestigation→(Resolved/Escalated)→ClosedArbitrageCaseEntityметод ChangeStatusarbitrage-case.md
INV-ARB-2Resolve требует Assignee + DecisionArbitrageCaseEntityметод Resolvearbitrage-case.md
INV-ARB-3Escalate — permission супервизора, ставит поля эскалацииArbitrageCaseEntityметод Escalatearbitrage-case.md
INV-ARB-4Привязки иммутабельны после Resolved/EscalatedArbitrageCaseEntityсеттеры/методыarbitrage-case.md
INV-ARB-5Заметки — только в Registered/UnderInvestigationArbitrageCaseEntityметод AddNotearbitrage-case.md

handbook

Справочники — реплики внешнего Talent Handbook (ADR-0063): пишет только синк, монотонность — по времени события (per-record версии у источника нет).

IDИнвариантСущностьГде проверяетсяДокумент
INV-HND-1Создание и правка реплика-полей — только синк источника; админу доступны лишь archive/restoreсправочники (country/region/city/currency/language)состав операций (нет write-API)country.md
INV-HND-2ISO-коды уникальны среди неархивных записей своего справочникаCountry/Currency/Languageсинк/restore + uniquecountry.md
INV-HND-3Гео-цепочка city↔region↔country согласована (город не ссылается на регион чужой страны)City/Regionприменение city-событияcity.md
INV-HND-4Монотонность по времени: событие применяется только при envelope.CreatedAt > LastEventAtвсе справочникиконсюмер (skip stale)country.md
INV-HND-5Публичный lookup — только неархивные; internal resolve — включая архивные (исторические ссылки не ломаются)все справочникичтение lookup/resolvecountry.md
INV-HND-6IsoCode валюты после trim — ровно 3 буквы ISO-4217 (в реплике char(3))Currencyприменение событияcurrency.md

calendar

IDИнвариантСущностьГде проверяетсяДокумент
INV-CAL-1Интервал: EndAt > StartAt; EndAt = null только для Kind ∈ {Task,BookingDeadline}CalendarEventEntityсоздание/изменение/переносcalendar-event.md
INV-CAL-2Переходы статуса по матрице; startInProgressCalendarEventEntityоперации/каскадыcalendar-event.md
INV-CAL-3Origin=Appointment меняется только каскадами записи (прямые операции агента отклоняются)CalendarEventEntityвсе операции измененияcalendar-event.md
INV-CAL-4Origin=CrmBooking меняется только консюмерами CRM (booking/deal-события)CalendarEventEntityвсе операции измененияcalendar-event.md
INV-CAL-5Kind-обязательности: BookingDeadlineBookingId+DealId; MeetingLink — только Consultation/MeetingCalendarEventEntityсоздание/изменениеcalendar-event.md
INV-CAL-6Пересечение интервалов одного OwnerUserId — soft-conflict (HasConflicts=true, не блокирует)CalendarEventEntityвыборки календаряcalendar-event.md
INV-CAL-7Терминальные Completed/Cancelled иммутабельны; Cancelled требует CancelReasonCalendarEventEntityвсе операции измененияcalendar-event.md
INV-CAL-8Санкц. переназначение OwnerUserId открытых deal-событий консюмером deal-событий (смена исполнителя/уход агента), в том же TenantId; Origin=Appointment не переназначаетсяCalendarEventEntityконсюмер deal-событийcalendar-event.md
INV-SLT-1StartAt < EndAt; слот только batch-публикацией с StartAt ≥ now+MinLeadTimeAvailabilitySlotEntityпубликация батчаavailability-slot.md
INV-SLT-2Слоты одного агента в Available/Booked не пересекаются (пересекающиеся кандидаты пропускаются)AvailabilitySlotEntityпубликация батчаavailability-slot.md
INV-SLT-3Занятие слота — условный переход Available→Booked под блокировкой, атомарно с записью и событием; побеждает одинAvailabilitySlotEntityбронированиеavailability-slot.md
INV-SLT-4Booked-слот управляется только каскадами записи (прямая архивация запрещена)AvailabilitySlotEntityarchive/каскадыavailability-slot.md
INV-SLT-5Бронирование пока now ≤ StartAt−MinLeadTime; Expired ставит worker/каскад; Archived терминаленAvailabilitySlotEntityбронирование/workeravailability-slot.md
INV-APT-1≤ 1 активной записи (Pending/Confirmed) на слот; создание атомарно со слотом и событиемAppointmentEntityunique partial + INV-SLT-3appointment.md
INV-APT-2Антиспам: ≤ 1 активной на пару (Investor,Agent,Kind) и ≤ 3 активных на инвестораAppointmentEntityunique partial + подсчётappointment.md
INV-APT-3Отмена/перенос инвестором — пока now ≤ StartAt−CancellationCutoff; агент не связанAppointmentEntitycancel/rescheduleappointment.md
INV-APT-4Исход (Completed/NoShow) — только агент, из Confirmed, после StartAtAppointmentEntitycomplete-appointmentappointment.md
INV-APT-5Снапшот слота (время/Kind/ProjectId/PropertyObjectId) иммутабелен; «перенос» = отмена+новая запись со взаимными ссылкамиAppointmentEntityвсе операции; переносappointment.md
INV-APT-6Терминальные иммутабельны; каскады на слот/событие в одном изменении; CancelledBy обязателен при Cancelled; Expired — только workerAppointmentEntityвсе операцииappointment.md
INV-REM-1≤ 1 Pending-строка на офсет; DueAt = StartAt−OffsetMinutes согласован (пересоздание при переносе)EventReminderEntityматериализация/переносevent-reminder.md
INV-REM-2Терминальные Sent/Skipped иммутабельны; Sent — только диспетчер однократноEventReminderEntityдиспетчеризация/каскадыevent-reminder.md

wallet

IDИнвариантСущностьГде проверяетсяДокумент
INV-WLT-1Форма холдера по виду счёта (XOR): холдерский — ровно один холдер, системный — ни одногоWalletAccountEntityсоздание + db checkwallet-account.md
INV-WLT-2≤ 1 счёт на (холдер,AccountKind); один системный счёт каждого вида на платформу; гонка lazy — по uniqueWalletAccountEntitylazy-создание + uniquewallet-account.md
INV-WLT-3После проведения баланс холдерских счетов >= 0; системные штатно отрицательны; дебет сверх баланса — детектор рассинхронаWalletAccountEntityпроведение транзакцииwallet-account.md
INV-WLT-4Валюта проводки = валюте счёта; все счета RUB (ADR-0048)WalletAccountEntityпроведение (VO-guard)wallet-account.md
INV-WLT-5В транзакции ≥ 2 проводок и Σ дебетов = Σ кредитов, одна валютаLedgerTransactionEntityпроведение (до применения)ledger-transaction.md
INV-WLT-6Транзакции/проводки append-only; исправление — только Correction с причиной; двойная компенсация исключена uniqueLedgerTransactionEntityмодель операций + uniqueledger-transaction.md
INV-WLT-7≤ 1 транзакция на (OperationKind, SourceId); повтор — no-op (идемпотентность)LedgerTransactionEntityuniqueledger-transaction.md
INV-WLT-UDR-1Snapshot пользователя применяется только при user-upserted.Version > IdentityUserVersionUserDisplayReplicaEntityversion-guard консюмераuser-display-replica.md
INV-WLT-UDR-2Snapshot названия компании — при company-upserted.Version > IdentityCompanyVersion и совпадении CompanyIdUserDisplayReplicaEntityversion-guard консюмераuser-display-replica.md
INV-WLT-UDR-3DisplayName — детерминированное схлопывание FullNameSnapshot; пустой snapshot → пустая строкаUserDisplayReplicaEntityпроекция в консюмереuser-display-replica.md
INV-WLT-UDR-4При смене CompanyId CompanyName берётся из известной строки того же агентства, иначе null до company-upsertedUserDisplayReplicaEntityобработка user-upserteduser-display-replica.md
INV-WLT-UDR-5Реплика read-only (пишет только консюмер identity); отсутствие строки на read-path — не ошибкаUserDisplayReplicaEntityнет write вне консюмераuser-display-replica.md

rewards

Финансовый контур комиссий/выплат (ADR-0067/0068/0069). Часть агрегатов нумерует инварианты локально (INV-N Name) — дом инварианта разрешает колонка «Документ».

IDИнвариантСущностьГде проверяетсяДокумент
INV-1 PercentRange0 < CommissionPercent ≤ 100RewardRuleEntityвход + db checkreward-rule.md
INV-2 ConsistentRangesДиапазоны комнат/площади согласованы; EffectiveFrom < EffectiveToRewardRuleEntityвход + db checkreward-rule.md
INV-3 UniqueActivePriorityPriority уникален среди активных правил tenant-а (тай-брейк одной специфичности)RewardRuleEntitypartial-unique + Activatereward-rule.md
INV-4 ValidReferencesProjectIds/LayoutGroupIds/LayoutIds — в реплике chessboard и того застройщикаRewardRuleEntityActivate (сверка)reward-rule.md
INV-5 ImmutableWhenActiveВ Active — только сокращение EffectiveTo; прочее — в DraftRewardRuleEntityguard по Statusreward-rule.md
INV-6 ArchiveConsistencyStatus = Archived ⇔ DeletedAt != nullRewardRuleEntityоперация архивированияreward-rule.md
INV-7 TenantOwnershipАктор — менеджер TenantId c rewards.rules.manageRewardRuleEntitypermission-policyreward-rule.md
INV-8 SpecificityResolutionПодбор по специфичности оси каталога (Layout>LayoutGroup>Project>застройщик); Priority — тай-брейк внутри специфичностиRewardRuleEntityалгоритм ResolveRulereward-rule.md
INV-9 NoOverlapWithinSpecificityПериоды активных правил одной сигнатуры специфичности не пересекаютсяRewardRuleEntityActivate под блокировкойreward-rule.md
INV-PT-1 RetentionRange0.25 ≤ RetentionPercentagePoints ≤ 1.50PlatformCommissionTermsEntityвход + db checkplatform-commission-terms.md
INV-PT-2 NoOverlapWithinSpecificityПериоды активных условий одной специфичности не пересекаютсяPlatformCommissionTermsEntityActivate под блокировкойplatform-commission-terms.md
INV-PT-3 ImmutableWhenActiveВ Active — только сокращение EffectiveTo; измерения/Retention — в DraftPlatformCommissionTermsEntityguard по Statusplatform-commission-terms.md
INV-PT-4 RetentionBelowRulePercentRetention < CommissionPercent правила (пул агентской стороны > 0)PlatformCommissionTermsEntityсоздание начисления (резолюция)platform-commission-terms.md
INV-PT-5 ObjectAxisTopDownОсь объекта сверху вниз: ProjectIdDeveloperCompanyId, LayoutGroupIdProjectIdPlatformCommissionTermsEntityCreate/Activateplatform-commission-terms.md
INV-RR-1 PercentRange0 < Percent ≤ 100ReferralRateRuleEntityвход + db checkreferral-rate-rule.md
INV-RR-2 NoOverlapWithinSpecificityПериоды активных ставок одной комбинации измерений (вкл. null) не пересекаютсяReferralRateRuleEntityActivate под блокировкойreferral-rate-rule.md
INV-RR-3 ImmutableWhenActiveВ Active — только сокращение EffectiveTo; прочее — в DraftReferralRateRuleEntityguard по Statusreferral-rate-rule.md
INV-RR-4 ScopeHierarchyConsistentВложенность измерений (ProjectIdDeveloperCompanyId, LayoutGroupIdProjectId); PartnerUserId ортогоналенReferralRateRuleEntityвход + db checkreferral-rate-rule.md
INV-1 OneAccrualPerDeal≤ 1 начисление на DealId (идемпотентность consumer-а)CommissionAccrualEntityunique + проверка до созданияcommission-accrual.md
INV-2 AmountFormulaCommissionAmount = база × CommissionPercent/100 (banker's, 2 знака)CommissionAccrualEntityCreate/CreateManualcommission-accrual.md
INV-3 ConfirmGuardConfirm из PendingConfirmation менеджером DeveloperCompanyIdCommissionAccrualEntityметод + policycommission-accrual.md
INV-4 DistributionOnConfirmТри строки распределения строятся ровно при ConfirmCommissionAccrualEntityметод Confirmcommission-accrual.md
INV-5 DistributionSumΣ Distribution = CommissionAmount; Platform=fee, Agency+Agent=пул (FixedAmount>пул → доля агента 0)CommissionAccrualEntityметод Confirmcommission-accrual.md
INV-6 SingleCurrencyВсе Money-поля начисления и строк — один CurrencyIdCommissionAccrualEntityCreate + VO-guardcommission-accrual.md
INV-7 PaidOnlyViaPayoutPaid только из MoneyReceived каскадом поручения (все Agent-строки выплачены)CommissionAccrualEntityMarkPaid (internal)commission-accrual.md
INV-8 CancelGuardCancel из PendingConfirmation/Confirmed (строки свободны и не в акте); из MoneyReceived/Paid запрещёнCommissionAccrualEntityметод Cancelcommission-accrual.md
INV-9 RecipientConsistencyРовно 3 строки Platform/Agency/Agent; у Platform/Agency RecipientUserId=nullCommissionAccrualEntityConfirm + db checkcommission-accrual.md
INV-10 PlatformFeeFormulaPlatformFeeAmount = база × RetentionPP/100; политика агентства делит только пулCommissionAccrualEntityCreate + пересчётcommission-accrual.md
INV-11 AdjustmentGuardsКорректировка gross/распределения по ролям (застройщик/ПИН-админ/руководитель; агент — только заявка); каждая — append-only строка с ReasonCommissionAccrualEntityоперации корректировки + policycommission-accrual.md
INV-12 AdjustmentRequestLifecycleЗаявка агента на долю: ≤ 1 Pending на начисление; одобрение руководителем исполняет Distribution; Approved/Rejected терминальныCommissionAdjustmentRequestEntityзаявочные операции + policycommission-accrual.md
INV-1 OnlyFreeSourceRowsИсточник строк по Kind: AgencyCommissionAgent-строки MoneyReceived; ReferralReward→рефералки Approved; свободные (PayoutOrderItemId is null)PayoutOrderEntityсборка состава под блокировкойpayout-order.md
INV-2 OneItemPerRecipientОдин получатель — одна позицияPayoutOrderEntityunique + агрегацияpayout-order.md
INV-3 SumConsistencyItem = Σ строк; TotalAmount = Σ Item; ItemsCount = count(Items)PayoutOrderEntityпересчёт при мутации составаpayout-order.md
INV-4 SingleCurrencyЕдиная CurrencyId позиций и TotalAmountPayoutOrderEntityсборка (VO-guard)payout-order.md
INV-5 ComposeOnlyInDraftПравка состава — только в DraftPayoutOrderEntityguard по Statuspayout-order.md
INV-6 ApproveGuardApprove из Draft при ItemsCount ≥ 1, rewards.payouts.approvePayoutOrderEntityметод + policypayout-order.md
INV-7 MarkPaidCascadeMarkPaid каскадит PaidAt позициям/строкам и переводит начисления MoneyReceived/Approved → PaidPayoutOrderEntityпереход MarkPaidpayout-order.md
INV-8 ReleaseOnTerminateReject/Cancel освобождают строки (PayoutOrderItemId → null); Reject требует причинуPayoutOrderEntityпереходы Reject/Cancelpayout-order.md
INV-9 TenantOwnershipСтроки принадлежат TenantId; ReferralReward — tenant ПИНPayoutOrderEntityизоляция по tenantpayout-order.md
INV-10 KindHomogeneityВсе позиции/строки одного Kind (агентские и реферальные не смешиваются)PayoutOrderEntityсборка составаpayout-order.md
INV-RA-1 OneAccrualPerDeal≤ 1 реферальное начисление на DealIdReferralAccrualEntityunique + проверка до созданияreferral-accrual.md
INV-RA-2 AmountFormulaAmount = FinalPrice × Percent/100 (banker's); валюта единая (на момент создания)ReferralAccrualEntityсоздание/корректировкаreferral-accrual.md
INV-RA-3 ApprovedOnlyViaMoneyReceivedPending→Approved только каскадом MoneyReceived родителя (прямого API нет)ReferralAccrualEntityрегистрация акта (каскад)referral-accrual.md
INV-RA-4 NoSelfReferralReferrerUserId ≠ AgentUserId сделки (иначе skip)ReferralAccrualEntityсоздание (consumer)referral-accrual.md
INV-RA-5 TerminalDisciplinePaid только каскадом реф-поручения; Cancel из Pending/свободного Approved; из Paid — нетReferralAccrualEntityкаскад выплаты + guardreferral-accrual.md
INV-RA-6 AmountAdjustableСумма корректируется first-class (причина+актор+append-only) пока свободно/не выплачено; из Approved — дельта проводкой CorrectionReferralAccrualEntityкорректировка суммыreferral-accrual.md
INV-RC-1 OnlyConfirmedSameDeveloperПозиция — начисление Confirmed того же застройщикаPlatformReceiptEntityвключение позиции + Registerplatform-receipt.md
INV-RC-2 SumConsistencyΣ Item = Amount акта; ItemsCount ≥ 1 при регистрацииPlatformReceiptEntityоперация Registerplatform-receipt.md
INV-RC-3 ExclusiveMembershipНачисление — максимум в одном не-отменённом актеPlatformReceiptEntityunique при включенииplatform-receipt.md
INV-RC-4 RegisteredImmutableAtomicCascadeRegistered терминален/неизменяем; регистрация атомарна (позиции→MoneyReceived, рефералки→Approved, события)PlatformReceiptEntityRegister + guard по Statusplatform-receipt.md
INV-1 SingleActivePolicy≤ 1 Active политика на агентство; Activate архивирует прежнююAgencyCommissionPolicyEntitypartial-unique + Activateagency-commission-policy.md
INV-2 ValidRuleValuesЗначения правил в диапазонах; FixedAmount требует CurrencyIdAgencyCommissionPolicyEntityпроверка документа + resolveagency-commission-policy.md
INV-3 UniqueOverridesAgentUserId уникален в AgentOverrides, активный пользователь TenantIdAgencyCommissionPolicyEntityдокумент + Activateagency-commission-policy.md
INV-4 ImmutableWhenActiveRules/Name в Active не меняются (правка — новый Draft)AgencyCommissionPolicyEntityguard по Statusagency-commission-policy.md
INV-5 ArchiveConsistencyStatus = Archived ⇔ DeletedAt != nullAgencyCommissionPolicyEntityоперация архивированияagency-commission-policy.md
INV-6 NoPolicyFallbackБез активной политики удержание агентства = 0, весь пул — агенту (удержание ПИН применяется независимо)AgencyCommissionPolicyEntityConfirm начисленияagency-commission-policy.md
INV-7 TenantOwnershipАктор — руководитель TenantId c rewards.commission-policy.manageAgencyCommissionPolicyEntitypermission-policyagency-commission-policy.md
INV-8 BoostRuleKindBoostRule.Kind ∈ {AgentPercentOfPool,AgentPercentOfPrice}, 0 ≤ Value ≤ 100AgentBoostCampaign (owned)проверка документаagency-commission-policy.md
INV-9 NoBoostOverlapPerAgentПо каждому агенту периоды активных boost-кампаний не пересекаютсяAgentBoostCampaign (owned)Activate под блокировкойagency-commission-policy.md
INV-10 BoostImmutableWhenActiveВ Active — только сокращение EffectiveTo; прочее и набор агентов — в DraftAgentBoostCampaign (owned)guard по Statusagency-commission-policy.md
INV-11 BoostTargetsValidAgentUserIds непуст, ≤ 1000, без повторов, активные пользователи TenantIdAgentBoostCampaign (owned)документ + Activateagency-commission-policy.md
INV-12 BoostPeriodValidEffectiveTo is null или EffectiveTo > EffectiveFromAgentBoostCampaign (owned)вход + db checkagency-commission-policy.md
INV-AP1Счётчики (FixationsApprovedCount/DealsWonCount/DealsLostCount) и суммы — >= 0AgentPerformanceEntityконсюмеры + checkagent-performance.md
INV-AP2CommissionPaidTotal ≤ CommissionAccruedTotalAgentPerformanceEntityконсюмеры + checkagent-performance.md
INV-AP3≤ 1 строка на пару (AgentUserId, CompanyId) (композитный PK)AgentPerformanceEntityPK + upsertagent-performance.md
INV-AP4Left ⇒ LeftAt/LeaveReason/EffectiveAt заполнены; Active ⇒ они nullAgentPerformanceEntityконсюмер membershipagent-performance.md
INV-AP5У агента допустимо несколько Active-строк (по одной на агентство; INV-AM1 снят, ADR-0070)AgentPerformanceEntityмодель мульти-членстваagent-performance.md
INV-AP6Display-статус InAgency ⇔ ≥ 1 Active-строка, иначе Independent (на чтении)AgentPerformanceEntityпроекция чтенияagent-performance.md
INV-AP7Перевод строки в Left не меняет её earned-показателиAgentPerformanceEntityконсюмер membershipagent-performance.md

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

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