57 KiB
module-07. Полная спецификация bitrix-sync
Статус: целевая постановка первого функционального релиза CRM-синхронизации.
Заменяет прежнюю спецификацию DB-connectivity stub.
Исходный концепт:sync-service-concept.md.
Связанные контракты:../architectory/arch-01-system-architecture.md,../architectory/arch-02-api-contracts.md,module-01-api-backend.md,module-10-deployment-runbook.md.
1. Назначение и границы
bitrix-sync асинхронно связывает пользователя HAN с Contact Битрикс24 и поддерживает согласованное состояние профиля без синхронной зависимости пользовательских API от CRM.
В первый релиз входят:
contact.map_or_create— первичный поиск/создание Contact и сохранение связи;contact.update— передача принадлежащих App полей в уже связанный Contact;contact.deactivate— снятие признака активной регистрации и закрытие mapping;contact.rebind— управляемое исправление ошибочной связи с другим Contact;- сигнал HTTP-webhook робота Contact → App и обновление локального профиля;
- инкрементальная reconciliation на случай потерянных webhook;
- business alerts по конфликтам через смарт-процесс Битрикс24;
- durable workflow, batch, rate limit, retry, technical DLQ, audit и observability.
Вне первого релиза:
- Lead/Deal;
- документы компании в профиле;
- автоматический merge CRM-дублей;
- публичный/manual replay HTTP API;
- backfill пользователей, зарегистрированных до cutover;
- полная reconciliation всех Contact; в первом релизе выполняется только инкрементальная сверка;
- создание
UserIdentityилиClientProfile; - использование OAuth credential
bitrix-local-app.
2. Принципы
- PostgreSQL — source of truth очереди, workflow и mapping; оперативная память не хранит единственное состояние сценария.
- Delivery semantics — at-least-once. Идемпотентность обязательна для каждой внешней операции.
- Сценарий хранится как state machine, а не как заранее созданный произвольный DAG атомарных команд.
han_app.sync_queueсодержит бизнес-намерения App;bitrix_sync.crm_commandsсодержит конкретные вызовы CRM.- Один CRM-command соответствует одному подзапросу
batch. - Телефон используется для поиска только при первичном создании связи.
- После формирования связи Contact читается и обновляется только по
b24_id. - Запрашиваются только необходимые поля.
- Business conflicts и technical failures имеют разные журналы и каналы эскалации.
- Секреты и PII не записываются в payload очередей, логи, traces и метрики.
- Runtime
bitrix-syncне выполняет HTTP-вызовов на ВМ1 и не направляет через неё inbound/outbound CRM traffic; взаимодействие с App идёт через managed PostgreSQL.
3. Владение данными и field mapping
3.1. Mastership
UserIdentity.phone_number— master App/Keycloak.ClientProfile.full_name— master Битрикс24, источникContact.NAME.ClientProfile.citizenship— master Битрикс24.ClientProfile.email— master Битрикс24.user_idи registration flag — служебные поля интеграции с логическими значениямиactive/inactive; в подтверждённом фильтре universal CRMactiveкодируется как1.- App DB — локальный read model для UI.
ClientProfile.foreign_phoneне синхронизируется в первом релизе и не должен создаватьcontact.update.
После первичного map/create App не отправляет в CRM full_name, citizenship и email. Изменения этих полей приходят только через webhook/reconciliation и записываются с SET LOCAL han.sync_suppress='true'.
3.2. Non-secret env mapping
Физические имена полей конкретного портала задаются non-secret env:
BITRIX_SYNC_CONTACT_USER_ID_FIELD=UF_CRM_1785934432398
BITRIX_SYNC_CONTACT_REGISTERED_FIELD=UF_CRM_1778692456
BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD=UF_CRM_1768493029
Значения обязательны при BITRIX_SYNC_ENABLED=true, проверяются на startup по допустимому формату имени поля. Изменение требует контролируемого restart.
Для методов universal CRM физическое имя UF_CRM_<digits> детерминированно преобразуется в REST-имя ufCrm_<digits>. В частности, BITRIX_SYNC_CONTACT_REGISTERED_FIELD=UF_CRM_1778692456 используется в crm.item.list как ufCrm_1778692456. Произвольное изменение регистра или иной алгоритм преобразования запрещены; обе формы валидируются на startup.
3.3. Преобразования
- телефон принимается только в валидном российском E.164
+7XXXXXXXXXX; - sync-service не исправляет
8..., пробелы, скобки или дефисы; - невалидный телефон — permanent business failure до исправления источника;
- поиск выполняется
crm.duplicate.findbycommсtype=PHONE,entity_type=CONTACT; full_name = Contact.NAME;LAST_NAMEиSECOND_NAMEне конкатенируются;- citizenship в App — display value элемента списка;
- Bitrix enum ID и момент загрузки справочника сохраняются в sync snapshot;
- email: первый синтаксически валидный элемент с
VALUE_TYPE=WORK, иначе первый валидный элемент; - отсутствие значения в CRM очищает соответствующее CRM-master поле App.
Справочник citizenship загружается через crm.contact.userfield.list/эквивалентный актуальный метод, кэшируется с TTL и обновляется при неизвестном enum ID. Неизвестный ID не превращается в пустое значение: команда retry один раз после refresh, затем создаётся business alert.
4. Идентичность и mapping
Каноническая связь хранится только в bitrix_sync.entity_external_mapping. Схема han_app и ClientProfile не хранят b24_id: для App это внутренняя деталь интеграции.
Инварианты:
- у одного active
user_idне более одного active Contact mapping; - один
b24_idне может быть активным для двух active пользователей; - исторические mapping не удаляются;
user_idв CRM содержит UUIDUserIdentity.id;- закрытие mapping фиксирует
closed_at,close_reasonи correlation/workflow ID; - mapping, rebind workflow и их аудит принадлежат исключительно
bitrix-sync.
Перед телефонным поиском workflow проверяет active mapping по user_id. Если mapping отсутствует, выполняется поиск по телефону. Если среди найденных Contact уже есть Contact с тем же user_id, локальный mapping восстанавливается без повторного create; CRM update допускается только для отличающегося registration flag.
5. Сценарии
5.1. contact.map_or_create
Вход: eligible active user_id (identity и profile находятся в допускающем синхронизацию active status), задача из han_app.sync_queue.
Общий порядок:
- Получить актуальные
UserIdentityиClientProfile. - Проверить active mapping.
- При отсутствии mapping выполнить
crm.duplicate.findbycommпо телефону. - Получить только поля найденных Contact: ID, NAME, PHONE, EMAIL, citizenship,
user_id, registration flag, CREATED_TIME и source update timestamp. - Если среди результатов есть Contact с тем же
user_id, восстановить локальный mapping без повторного create, обновить registration flag только при расхождении и перейти к шагу 9. - Применить ветку В0–В2 (если на предыдущем шаге не завершен сценарий).
- Обновить Contact.
- Создать active mapping в
bitrix_sync. - Для существующего/восстановленного Contact записать его CRM-master поля в App с
SET LOCAL han.sync_suppress='true'и сохранить source snapshot; для созданного Contact сохранить snapshot фактически записанных значений. - При наличии конфликта создать/обновить business alert.
- Завершить workflow и исходную queue task.
Ветки:
- В0, Contact не найден: создать Contact с телефоном, доступными начальными значениями,
user_id, registration flagactive; после ambiguous timeout повторить поиск по телефону и не повторять create вслепую. - В1.1, один Contact без active связи: записать
user_id, flagactive, создать mapping. - В1.2, один Contact связан с другим active user: создать новый Contact для текущего пользователя и business alert.
- В2.1, несколько Contact, самый новый свободен: выбрать самый новый по
CREATED_TIME, затем по числовому ID как deterministic tie-breaker; создать mapping и alert о дублях. - В2.2, несколько Contact, выбранный связан с другим active user: создать новый Contact и alert с полным перечнем candidate ID.
Если найден Contact со старым user_id неактивного/удалённого пользователя:
- закрыть старый active mapping, если он ещё открыт;
- записать новый
user_idв тот же Contact; - создать новый active mapping;
- сохранить историю прежней связи.
5.2. contact.update
Вход: изменение App-master данных уже связанного active пользователя. Исключая изменения, связанные с обновлением данных контакта в Битрикс.
- Найти Contact только по active mapping.
- Отправлять только изменившиеся App-master поля: в первом релизе телефон и при необходимости служебные поля.
- При отсутствии mapping не выполнять телефонный поиск: создать/coalesce
contact.map_or_create. - При
crm.contact.get/updatenot found пометить mappingbroken, сохранить последний profile snapshot и создать business alert.
5.3. contact.deactivate
Триггер: переход UserIdentity.record_status или ClientProfile.record_status из active в inactive/deleted.
- Найти Contact по active mapping.
- Установить registration flag
inactive. - Закрыть mapping с причиной деактивации.
- Не удалять Contact и историю mapping.
- Если Contact уже отсутствует, закрыть mapping как
broken/deactivated, сохранить profile snapshot и создать alert.
5.4. contact.rebind
Перепривязка не выполняется непосредственным UPDATE mapping. Администратор вызывает audited procedure:
bitrix_sync.request_bitrix_contact_rebind(
user_id,
target_b24_id,
reason,
operator_id
)
Процедура:
- блокирует текущий active mapping пользователя;
- проверяет отсутствие active mapping целевого
b24_idс другим пользователем; - создаёт
rebind_requestи workflowcontact.rebind; - не изменяет действующий mapping до завершения CRM-команд.
Worker:
- читает прежний и целевой Contact по ID;
- повторно проверяет, что целевой Contact существует и не содержит
user_idдругого active пользователя; - записывает в целевой Contact текущий
user_idи registration flagactive; - очищает
user_idи устанавливает flaginactiveв прежнем Contact, только если там всё ещё находится ожидаемыйuser_id; - после успеха обеих CRM-команд одной транзакцией закрывает старый mapping, создаёт новый active mapping и завершает
rebind_request; - завершает связанный business alert только после успешного изменения mapping.
Если обновление целевого Contact прошло, а очистка прежнего завершилась временной ошибкой, workflow остаётся waiting_retry: новый mapping ещё не активируется, повторная команда проверяет текущее значение и безопасно продолжает сценарий. Все шаги идемпотентны.
5.5. Bitrix24 → App
- Webhook принимается и durable сохраняется в
webhook_inbox. - Несколько необработанных событий одного Contact coalesce в одно чтение.
- По
b24_idищется active mapping. - Contact запрашивается по ID с минимальным
select. - Проверяются
user_idи registration flag. - CRM-master поля записываются в App одной транзакцией с
SET LOCAL han.sync_suppress='true'. - Обновляются
source_updated_at, sync snapshot и webhook status.
Webhook для неизвестного/неактивного mapping подтверждается без изменения App и фиксируется low-cardinality audit. Несовпадение user_id с active mapping создаёт business alert и не перезаписывает профиль.
Если Contact не найден:
- mapping становится
broken; - последний snapshot и UI-данные сохраняются;
- профиль помечается stale через sync metadata, а не очищается;
- создаётся business alert.
6. PostgreSQL-контракты
6.1. han_app.sync_queue
Существующая таблица мигрируется без потери записей. Требуемые поля:
id uuid PK
task_type varchar(64)
entity_type varchar(64)
entity_id uuid
dedup_key varchar(255)
payload_json jsonb
status varchar(16)
attempt_count integer
next_attempt_at timestamptz
locked_by varchar(128) NULL
locked_until timestamptz NULL
lease_token uuid NULL
last_error_code varchar(64) NULL
last_error_at timestamptz NULL
completed_at timestamptz NULL
cancel_reason varchar(255) NULL
created_at timestamptz
updated_at timestamptz
Допустимые статусы:
pending | leased | processed | retry_wait | dead_letter | cancelled
Индексы:
(status, next_attempt_at, created_at)для claim;(locked_until) WHERE status='leased';(entity_type, entity_id, created_at DESC);- partial unique dedup только для активных состояний
pending|leased|retry_wait.
Глобальная UNIQUE(dedup_key) удаляется. Завершённая, отменённая или dead-letter задача не должна навсегда запрещать новое бизнес-событие.
Claim выполняется короткой транзакцией через FOR UPDATE SKIP LOCKED, выставляет locked_by, locked_until, новый lease_token. Любое завершение проверяет тот же token; просроченный worker не может подтвердить чужую lease.
6.2. Trigger contract
han_app.enqueue_contact_sync():
- всегда использует
UserIdentity.idкакentity_id/user_id; - проверяет
han.sync_suppress; - не создаёт задачу при
IS NOT DISTINCT FROMдля фактически отслеживаемых полей; - insert active identity/profile создаёт/coalesce
contact.map_or_create; trigger не читает схемуbitrix_sync, наличие mapping проверяет worker; - изменение
UserIdentity.phone_numberсоздаётcontact.update; - переход любого record status из active создаёт
contact.deactivate; - возврат в active создаёт/coalesce
contact.map_or_create; - изменения CRM-master profile fields сами по себе не создают App→CRM задачу;
- trigger и business update находятся в одной транзакции.
Payload содержит только идентификаторы и безопасные версии, но не копии PII:
{
"schema_version": 1,
"user_id": "uuid",
"reason": "identity_phone_changed",
"source_updated_at": "2026-08-05T10:00:00Z"
}
Worker всегда читает актуальное состояние таблиц; payload не считается snapshot профиля.
6.3. bitrix_sync.entity_external_mapping
Таблица принадлежит migration/runtime boundary bitrix-sync:
id uuid PK
entity_type varchar(64)
entity_id uuid
external_system varchar(32) = 'bitrix24'
external_entity_type varchar(32) = 'contact'
external_id varchar(128)
status varchar(16) -- active|closed|broken
opened_at timestamptz
closed_at timestamptz NULL
close_reason varchar(64) NULL
workflow_id uuid NULL
created_at timestamptz
updated_at timestamptz
Ограничения:
- partial unique active
(external_system, entity_type, entity_id); - partial unique active
(external_system, external_entity_type, external_id); - active row не имеет
closed_at; - closed/broken row имеет
closed_atили документированную причину broken.
Администратору не выдаётся произвольный write на таблицу. Procedure bitrix_sync.request_bitrix_contact_rebind(...) создаёт durable запрос и workflow, но не изменяет mapping непосредственно.
Существующая han_app.entity_external_mapping мигрируется:
- создать целевую таблицу и ограничения в
bitrix_sync; - перенести и проверить существующие связи;
- переключить worker/repositories;
- удалить GRANT и зависимости App от старой таблицы;
- удалить
han_app.entity_external_mapping; - отдельной contract migration удалить partial index и колонку
ClientProfile.bitrix_contact_id.
До contract-фазы запрещено поддерживать две writable копии mapping.
6.4. Таблицы bitrix_sync
Минимальный набор:
workflow_instances— тип, user/contact ID, state, current step, source task, deadline, outcome, timestamps;crm_commands— workflow, command type, safe request params, status, attempt counters, lease, batch/correlation ID, safe response projection;webhook_inbox— receiver type, event type, optional event ID/source timestamp, entity IDs, received/status timestamps, optional dedup fingerprint и source IP audit metadata;business_alerts— sequence number, fingerprint, type, severity, app user, Contact candidates, selected ID, Bitrix smart-process item ID/stage, occurrence count;rebind_requests— user ID, прежний/целевой Contact ID, reason/operator, workflow, status и audit timestamps;contact_snapshots— IDs, field hashes/versions, citizenship enum ID, CRM source timestamps, last applied source (webhook|reconciliation|app_create) и last webhook received/source timestamps; без бесконтрольного дублирования PII;settingsиsettings_versions— runtime business/worker settings с типом, validation status, version и audit;technical_dead_letters— operation/workflow, safe error code, attempt/deadline metadata;reconciliation_cursors— job type, watermark, overlap и last success.
crm_commands.command_type — закрытый code enum/CHECK. БД не хранит произвольные REST method names или шаблоны исполняемых запросов.
7. State machines
7.1. Workflow
created
-> running
-> waiting_crm
-> waiting_retry
-> waiting_manual
-> succeeded | failed | cancelled
Только persisted transition активирует следующий шаг. Один workflow имеет не более одной активной команды, кроме явно независимых операций alert/status.
7.2. CRM command
pending -> leased -> in_flight -> succeeded
\-> retry_wait -> pending
\-> uncertain -> reconcile -> succeeded|retry_wait|dead_letter
\-> dead_letter
uncertain обязателен для timeout после отправки create/update. Для create reconciliation повторяет поиск по телефону и среди результатов в первую очередь проверяет Contact с тем же user_id.
7.3. Webhook
received -> coalesced|processing -> processed
\-> retry_wait -> processing
\-> dead_letter
8. Worker, batching и rate limit
Одна replica первого релиза, но все claims и leases безопасны для будущих нескольких replica.
- PostgreSQL
LISTEN/NOTIFYиспользуется только как wake-up optimization; - резервный polling обязателен;
- batch flush: достижение configured size либо configured max wait;
- default batch size
20, допустимый диапазон1..50; - один HTTP batch может содержать команды разных workflow, если они независимы;
- результат каждого подзапроса разбирается отдельно;
- FIFO определяется
next_attempt_at, created_at, но retry не блокирует новые задачи; - общий token bucket на portal/credential;
- безопасный default
2HTTP requests/sec до подтверждения тарифа; - burst и refill конфигурируются;
- default max in-flight HTTP requests
2; - медленный запрос не блокирует новый, пока доступен token и in-flight slot;
- несколько replica координируют limiter через PostgreSQL; локальный limiter допустим только при одной replica и явном readiness guard.
9. Retry и DLQ
Transient:
- connect/read timeout;
- временный DNS/TLS/network failure;
- HTTP 408/429/5xx;
QUERY_LIMIT_EXCEEDED;- временная недоступность PostgreSQL.
Политика: exponential backoff с full jitter, Retry-After/operating_reset_at приоритетнее локального delay, общий retry horizon 24 часа.
QUERY_LIMIT_EXCEEDED не расходует обычный business-attempt budget: он уменьшает limiter rate и переносит команду. OPERATION_TIME_LIMIT блокирует только затронутый method class до reset.
Permanent:
- invalid field/configuration;
- permission denied;
- credential rejected;
- нарушенный DB invariant.
Malformed App/CRM business data, включая невалидный телефон и неизвестный citizenship enum после refresh, переводит workflow в waiting_manual и создаёт/coalesce business alert; это не technical DLQ. Permanent technical failure немедленно попадает в technical DLQ. Credential/config/permission failure переводит readiness в not-ready или degraded по матрице и создаёт SigNoz alert. Задача в смарт-процессе для technical failures не создаётся.
В лог/БД сохраняются safe error code, HTTP status, Bitrix error code, attempt/deadline и correlation ID. Сырой response и exception text проходят redaction; утверждение «внутренний сервис» не разрешает логировать PII или секреты.
10. Webhook receivers
10.1. Адреса приёма
Contact receiver:
https://<processing-public-host>/bitrix/sync/webhook/contact?token=<contact-receiver-token>
Alert receiver:
https://<processing-public-host>/bitrix/sync/webhook/alert?token=<alert-receiver-token>
Эти адреса задаются в HTTP-webhook роботах Битрикс24 и ведут прямо в receiver routes bitrix-sync. Это не callbacks local app и не Bitrix event handlers. Установка local app, event.bind, OAuth и application_token для CRM sync не используются; auth[member_id] служит только дополнительной проверкой источника webhook.
Фактический контракт штатного HTTP-webhook робота Битрикс24:
POST <receiver>?token=<receiver-token>&ID=<entity-id>
Content-Type: application/x-www-form-urlencoded
document_id[0]=crm
document_id[1]=CCrmDocumentContact
document_id[2]=CONTACT_<contact-id>
auth[domain]=<portal-host>
auth[client_endpoint]=https://<portal-host>/rest/
auth[server_endpoint]=https://oauth.bitrix24.tech/rest/
auth[member_id]=<portal-member-id>
Для alert document_id[1] содержит тип dynamic document, а document_id[2]=DYNAMIC_<expected-entity-type-id>_<item-id>. Значение query ID должно совпадать с ID из document_id[2]; расхождение отклоняется как malformed request.
Штатный робот не позволяет передать Bearer header, поэтому отдельный высокоэнтропийный Contact/alert receiver token передаётся только в query. Это принятое ограничение платформы, а не предпочтительный способ аутентификации. Токены различаются между receiver и окружениями, сравниваются constant-time и поддерживают контролируемую ротацию с коротким периодом перекрытия.
Query token не должен попадать в access/error/audit logs, traces, метрики, Referer или diagnostic response. Nginx и приложение логируют нормализованный route без $request_uri/query; redirect для receiver routes запрещён. Примеры, fixtures и документация используют только placeholder, реальные значения из наблюдений должны быть отозваны, если они когда-либо сохранялись вне secret manager.
Публичный nginx ВМ2:
- принимает только HTTPS;
- ограничивает body, request rate и методы;
- до проксирования проверяет source IP TCP-соединения по явно заданному allow-list CIDR и возвращает
403для остальных адресов; - проксирует в локальный upstream
bitrix-syncпо закрытой Docker network; - не логирует body/query secrets;
- не публикует internal status;
- возвращает generic errors без stack/IDs.
Allow-list применяется к адресу непосредственного peer, а не к недоверенному X-Forwarded-For. Если перед nginx появляется внешний trusted proxy/LB, схема извлечения real IP и список trusted proxy должны пройти отдельный deployment review. Автоматическое добавление IP по входящему запросу запрещено.
Для отклонённых по IP запросов сохраняются только timestamp, source IP, receiver route и outcome с ограниченным retention; query и body не сохраняются. Счётчики агрегируются по receiver и причине без IP label. Source IP разрешается использовать только для диагностики и контролируемого обновления allow-list.
Nginx/его telemetry pipeline экспортирует отклонения в общую observability как webhook_rejected_total{receiver,reason="source_ip"}; приложение не может сформировать эту метрику, поскольку запрещённый запрос до upstream не доходит.
Bitrix24 обращается непосредственно к отдельному public host ВМ2. ВМ1 не принимает и не проксирует CRM webhook, не хранит его TLS/route configuration и не является runtime-зависимостью bitrix-sync.
До durable insert receiver проверяет query token, POST, application/x-www-form-urlencoded, bounded body/query, допустимый document type, entity ID и его совпадение в query/body, ожидаемый alert entity_type_id, portal domain и member_id. Переданные client_endpoint/server_endpoint не используются как адреса исходящих запросов. Неверная аутентификация или source IP возвращает 403, malformed contract — 400; такие запросы не создают inbox row.
Робот не передаёт достоверные event ID и occurred timestamp. received_at назначается сервером, а порядок и актуальность определяются последующим чтением Contact и нормализованным CRM source timestamp. Каноническое поле universal CRM — updatedTime; если точечное чтение выполняется legacy-методом crm.contact.get, его DATE_MODIFY преобразуется в тот же source_updated_at только после contract test эквивалентности. Для событий без стабильного event ID постоянная уникальность по fingerprint не применяется: несколько pending событий одного receiver/entity coalesce в одно чтение, а повторная обработка безопасна по Contact ID и source timestamp. Если Bitrix позднее предоставит стабильный event ID, он может использоваться для точной дедупликации.
После успешного durable insert возвращается 200/202; бизнес-обработка в HTTP request не выполняется.
HTTP-webhook робота не считается гарантированной доставкой, если в портале явно не подтверждена retry policy; поэтому reconciliation обязательна.
10.2. Contact reconciliation
Для active mapped Contact выполняется инкрементальная сверка:
crm.item.listсentityTypeId=3(Contact);- фильтр
>=updatedTime: watermark-overlap,opened: 1и<registered-field-rest-name>: 1; - первый проход выбирает только
id, затем изменившиеся active mapped Contact читаются по ID с минимальным набором CRM-master и служебных полей; - pagination и batch;
- watermark двигается только после полного успешного инкрементального run, а не после отдельной страницы;
- overlap обеспечивает повторное чтение границы;
- повтор безопасен по Contact ID/source timestamp;
- default interval 15 минут;
- рассчитано на объём до 10 000 active Contact.
Канонический запрос поиска кандидатов отправляется POST к REST method crm.item.list с Content-Type: application/json и Accept: application/json:
{
"entityTypeId": 3,
"select": ["id"],
"filter": {
">=updatedTime": "2026-08-05T09:00:00",
"opened": 1,
"ufCrm_1778692456": 1
}
}
Timestamp в запросе — вычисленный watermark-overlap, а имя custom field — REST-форма BITRIX_SYNC_CONTACT_REGISTERED_FIELD; приведённые значения являются примером контракта. Фильтры opened=1 и registration flag обязательны: reconciliation не сканирует все Contact портала.
Wire-значения записи registration flag через выбранный add/update method проверяются отдельным contract test портала. Логика сервиса не смешивает active/inactive с конкретным представлением 1/0 или Y/N; repository adapter выполняет подтверждённое преобразование.
Полная сверка всех Contact в первом релизе не выполняется и не планируется по расписанию. Решение о её внедрении принимается отдельно по production-метрикам потерь и расхождений.
Каждый инкрементальный run по source attribution и timestamp в contact_snapshots считает Contact, для которых CRM-master состояние оказалось новее локального snapshot и было восстановлено reconciliation без ранее обработанного webhook. Всплеск абсолютного числа или доли таких Contact относительно настроенного порога/обычного baseline создаёт ops alert о возможной потере webhook.
Runbook по этому alert:
- сравнить время всплеска с
webhook_rejected_total{reason="source_ip"}и безопасным журналом отклонённых source IP; - проверить, что новый адрес действительно принадлежит инфраструктуре Битрикс24/портала, используя согласованный канал или контролируемый probe; одного факта запроса с корректным token недостаточно;
- при подтверждении изменить version-controlled allow-list CIDR, пройти review и применить конфигурацию nginx;
- убедиться, что webhook снова принимаются, а последующие инкрементальные run не находят растущих расхождений.
Allow-list никогда не расширяется автоматически по метрике или журналу. Инкрементальная reconciliation восстанавливает пропущенные изменения, поэтому блокировка нового легитимного IP ухудшает latency, но не должна приводить к окончательной потере согласованности.
10.3. Alert reconciliation
Изменение элемента смарт-процесса приходит через alert HTTP-webhook робот. Дополнительно открытые alerts сверяются batch-poll:
- default каждые 60 минут;
- только незавершённые item ID;
- terminal stage закрывает локальный alert;
- удалённый item переводит alert в
remote_missingи создаёт ops/business signal согласно типу.
11. Business alerts
Смарт-процесс «Конфликты синхронизации» создаётся до production enablement.
Параметры хранятся в versioned bitrix_sync.settings:
entity_type_id;- category/pipeline ID;
- stage IDs
new,in_progress,resolved,closed_without_resolution; - field IDs;
- responsible user/group;
- reconciliation interval;
- SLA один рабочий день.
Обязательные поля элемента:
- внутренний
alert_number; - fingerprint;
- type/severity;
- app user ID;
- current/selected/candidate Contact IDs;
- masked details;
- occurrence count;
- first/last occurrence;
- previous alert link;
- service correlation ID.
Один открытый alert существует на (type, fingerprint). Повтор увеличивает occurrence_count и обновляет item. После terminal stage новый случай создаёт новый alert со ссылкой на предыдущий.
После разбора конфликта администратор запускает bitrix_sync.request_bitrix_contact_rebind(...). Простая смена стадии alert не изменяет mapping. Sync-service переводит alert в terminal stage только после успешного contact.rebind; произвольный SQL запрещён.
12. Настройки
12.1. Runtime secrets
Обязательный каталог для secret manager:
BITRIX_SYNC_DATABASE_URL
BITRIX_SYNC_CRM_REST_WEBHOOK_URL
BITRIX_SYNC_CONTACT_RECEIVER_TOKEN
BITRIX_SYNC_ALERT_RECEIVER_TOKEN
BITRIX_SYNC_SERVICE_TOKEN
BITRIX_SYNC_CRM_REST_WEBHOOK_URL — credential-bearing URL входящего webhook Битрикс24, через который sync-service вызывает CRM REST. Он не связан с двумя public receiver URLs ВМ2 и считается единым секретом. Сервис не собирает его из логируемых частей. Receiver tokens также остаются secret-manager values, хотя из-за ограничения робота подставляются в query настроенных URL; доступ к конфигурации роботов Битрикс24 должен быть ограничен.
Секреты доставляются существующим han-secrets из Selectel Secrets Manager в read-only files /run/han-chat/secrets; fallback для no-egress VM — root-owned files. Они не хранятся в .env или БД.
12.2. Non-secret env
BITRIX_SYNC_ENABLED=true
BITRIX_SYNC_MODE=full
BITRIX_SYNC_CONTACT_USER_ID_FIELD=UF_CRM_...
BITRIX_SYNC_CONTACT_REGISTERED_FIELD=UF_CRM_1778692456
BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD=UF_CRM_...
BITRIX_SYNC_PORTAL_HOST=<approved-host>
BITRIX_SYNC_PORTAL_MEMBER_ID=<approved-member-id>
BITRIX_SYNC_PUBLIC_BASE_URL=https://<processing-public-host>
BITRIX_WEBHOOK_ALLOWED_CIDRS=<comma-separated-cidrs>
BITRIX_SYNC_HTTP_TIMEOUT_SEC=10
BITRIX_SYNC_DB_POOL_SIZE=5
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
SECRETS_SOURCE=selectel
Portal host обязан совпадать с host secret URL, а portal member ID — с auth[member_id] входящих webhook; redirect на другой host запрещён. BITRIX_WEBHOOK_ALLOWED_CIDRS применяется deployment/nginx, проходит CIDR validation и version-controlled review; пустой allow-list при enabled receiver является ошибкой deployment-конфигурации. Nginx config test и проверка allow-list входят в preflight, поскольку application readiness не может подтвердить фактически загруженную конфигурацию внешнего proxy.
BITRIX_SYNC_MODE=full означает реализацию полного набора сценариев этого модуля, а не запуск full reconciliation. Существующие stub-era .env.example, Compose и deployment/secrets/config.example.json не являются контрактом full sync. В реализации они обновляются одновременно с кодом и manifest validation; до этого BITRIX_SYNC_ENABLED=false. В частности, api-backend не является consumer BITRIX_SYNC_SERVICE_TOKEN, а устаревшие BITRIX_SYNC_CRM_BASE_URL, CONTACT_*_INTERVAL_SEC и BITRIX_SYNC_CRM_MAX_CONCURRENCY не переносятся в full mode.
12.3. Hot settings
В bitrix_sync.settings: batch size/wait, claim size, lease TTL, limiter refill/burst, max in-flight, retry base/max/horizon, reconciliation intervals, пороги всплеска восстановленных reconciliation Contact, alert process IDs/stages/fields/SLA.
Worker читает active validated version, кэширует её и периодически проверяет version. Невалидная новая версия не активируется. Secrets, DSN, field names, portal host/member ID, inbound CIDR allow-list и cutover mode не меняются hot.
13. Минимальные права
Технический пользователь входящего webhook Б24:
- read Contact и перечисленных полей;
- add Contact;
- update только Contact в разрешённом CRM scope;
- read userfield metadata для citizenship;
- add/read/update элементы конкретного smart process alerts;
- без Lead/Deal delete/export/admin прав;
- без доступа к чатам, документам, телефонии и настройкам портала.
Точные ограничения проверяются smoke вызовами до enablement. Использование webhook администратора с полными правами запрещено.
DB role bitrix_sync_user:
- owner/write собственной схемы
bitrix_sync; SELECT, UPDATEтолько необходимых колонокhan_app.sync_queue;SELECTнужных колонок identity/profile;- controlled
UPDATEтолько CRM-master полей профиля иsource_updated_at; - без broad schema write, DDL и доступа к сообщениям/согласиям/документам;
- mapping/rebind tables находятся в собственной схеме и не требуют write GRANT на
han_app; - запись профиля App выполняется только через repository с transaction-local GUC.
14. HTTP health/status
GET /health/live: process/event loop жив, без DB/CRM вызова.GET /health/ready: validated application config/secrets, PostgreSQL доступен, queue grants работают, workers/limiter живы, webhook routes и portal identity сконфигурированы; краткая CRM outage даёт degraded по stale threshold, а invalid credential — not-ready. Состояние внешнего nginx/CIDR allow-list проверяется deployment preflight и отдельным synthetic probe, а не этим endpoint.GET /internal/sync/v1/status: Bearer service token, internal-only.
Status содержит mode, queue depth по состояниям, oldest age, active workflows, commands retry/DLQ, webhook lag, reconciliation cursors, limiter state, last CRM success и settings version. PII, URL credential, Contact/user IDs и raw errors не возвращаются.
15. Observability
Метрики:
- queue depth/oldest age и claim duration;
- workflow/command transitions;
- CRM batch size, duration и subcommand outcomes;
- limiter tokens/throttle/rate-limit errors;
- retry/DLQ counts и age;
- webhook accepted/rejected/coalesced/lag по receiver и safe reason, включая отклонение source IP без IP label;
- reconciliation scanned/updated/recovered-without-webhook/cursor lag и alert о всплеске восстановлений;
- business alerts open/SLA overdue;
- mapping invariant violations;
- readiness and worker heartbeat.
Labels только low-cardinality: command type, outcome, safe error code, event type. user_id, Contact ID, phone, email и fingerprint не labels.
JSON logs содержат event name, service/version/environment, request/trace/span/correlation/workflow/command IDs, safe status/error code. PII маскируется минимум до первых двух и последних двух читаемых символов; предпочтительно не логируется вообще. Secrets, URL webhook, request/response body и SQL parameters запрещены.
16. Security
- approved Bitrix portal — единственный CRM egress
:443; - TLS verify обязателен;
- egress redirect выключен либо повторно проверяет host allow-list;
- container non-root, read-only rootfs, dropped capabilities;
- public webhook проходит source IP CIDR allow-list и rate/body/method limits nginx ВМ2;
- internal status отсутствует в public server block nginx ВМ2;
- runtime secret files read-only и service-specific;
- constant-time token compare;
- query string и form body receiver routes исключены из всех журналов и traces;
- JSON/form parsing bounded;
- DB payloads не содержат PII snapshots без необходимости;
- зависимые образы/dependencies pinned и сканируются;
- test/prod — разные credentials, secrets, DB/settings и deployments.
Разделение тестовых и production Contact на одном портале является внешней организационной ответственностью. Поведение sync-service одинаково; test должен быть prod-like. Это принятое ограничение не отменяет отдельные credentials и endpoint tokens.
17. Cutover
Cutover не управляется постоянным replay=false на каждом startup.
Одноразовая процедура:
- Остановить старый stub/worker.
- Применить migrations и grants.
- Зафиксировать
cutover_watermark. - Перевести все существовавшие до watermark
pending/retrycontact-задачи вcancelledс причинойinitial_full_sync_cutover; словоfullздесь относится к переходу со stub на full mode, а не к reconciliation. - Не создавать backfill для active пользователей без mapping.
- Проверить env/secrets/settings/fields/rights/webhook.
- Запустить сервис disabled и выполнить preflight.
- Включить обработку только задач после watermark.
- Наблюдать queue/webhook/DLQ/CRM limits.
Принятое бизнес-ограничение: ранее зарегистрированные пользователи без mapping могут никогда не синхронизироваться, пока новое отслеживаемое событие не создаст задачу. Это не считается дефектом первого релиза.
Rollback:
- остановить claims;
- дождаться/ограниченно завершить in-flight;
- выключить sync без отмены новых pending;
- не откатывать уже созданные Contact/mapping автоматически;
- сохранить workflow/audit для последующего controlled resume;
- DB downgrade не выполняется, если появились production rows нового формата.
18. Тестовая матрица
Unit
- state transitions и запрещённые переходы;
- trigger decision/no-op/suppress;
- E.164 validation;
- branch В0–В2 и deterministic newest;
- field mapping/citizenship/email;
- coalesce событий без event ID и дедупликация при наличии стабильного event ID;
- backoff/jitter/24h horizon;
- limiter/batch flush;
- query token/domain/member/document ID validation;
- PII/secret redaction.
Integration PostgreSQL
- concurrent claim через
SKIP LOCKED; - lease expiry и fencing stale worker;
- trigger + business transaction atomicity;
- partial unique active mapping;
- миграция mapping из
han_appвbitrix_syncбез двух writable copies; - удаление
ClientProfile.bitrix_contact_idи его partial index после переключения readers; contact.rebindпри success, partial CRM failure, retry и конфликте целевого Contact;SET LOCAL han.sync_suppressбез leakage в pool;- crash после CRM success до local commit;
- settings activation/version rollback;
- cutover watermark cancellation.
Bitrix contract
crm.duplicate.findbycommс+7XXXXXXXXXX;crm.item.listдляentityTypeId=3с>=updatedTime,opened=1и registration field=1;- minimal select;
- add/get/update Contact;
- custom fields read/write;
- citizenship dictionary;
- batch mixed success/failure;
QUERY_LIMIT_EXCEEDEDиOPERATION_TIME_LIMIT;- smart-process create/get/update;
- Contact/alert HTTP-webhook robot form-urlencoded payload;
- missing/invalid/rotated receiver query token;
- совпадение query ID с
document_id, portal domain/member ID и alert entity type.
Failure
- timeout до/после отправки create;
- service restart на каждом workflow step;
- PostgreSQL/Bitrix/OTEL outage and recovery;
- lost/duplicate/out-of-order webhook;
- новый легитимный source IP вне allow-list с восстановлением изменений reconciliation;
- dead worker and lease recovery;
- malformed CRM response;
- credential revoked;
- mapping invariant violation;
- graceful SIGTERM с bounded drain.
Load
- вход 100 событий/мин при lag не более 30 секунд в healthy CRM;
- batch utilization;
- очередь растёт предсказуемо при throttle и восстанавливается после него;
- reconciliation до 10 000 Contact не нарушает основной SLA;
- отсутствие DB connection-pool starvation.
Security
- least-privilege negative tests;
- public receiver source IP allow-list, size/rate/auth;
- отсутствие query token и form body в nginx/app logs, traces и errors;
- SSRF/redirect host rejection;
- secrets absent in env dump/status/logs/traces;
- PII absent in metric labels и technical DLQ;
- internal endpoint недоступен через public host ВМ2.
19. Definition of Done
- три автоматических App→CRM сценария и административный
contact.rebinddurable и идемпотентны; - Contact HTTP-webhook робот и reconciliation обновляют App без echo;
- receiver принимает фактический form-urlencoded/query-token контракт, фильтрует source IP и не раскрывает query;
- canonical mapping и rebind audit находятся только в
bitrix_sync; - create восстанавливается после ambiguous outcome без дубля;
- business alerts создаются, coalesce и отслеживаются;
- technical failures идут в SigNoz/DLQ, а не в business process;
- batch/rate limit/retry соответствуют этой спецификации;
- secrets доставляются штатным manager и не раскрываются;
- health/status/metrics отражают реальное состояние;
- migrations, grants, OpenAPI и runbook синхронизированы;
- unit/integration/contract/failure/load/security gates пройдены;
- cutover и rollback отрепетированы;
- stub claims и
crm_sync_implemented=falseудалены из канонических документов.
20. Принятые решения и остаточные риски
Принято:
- один портал и отдельный credential-bearing CRM REST webhook технического пользователя;
- одна replica с HA-safe leases;
- default REST rate 2 HTTP requests/sec;
- два HTTP-webhook робота с отдельными query tokens, source IP allow-list и инкрементальной reconciliation;
- canonical mapping в
bitrix_sync.entity_external_mapping; - строгий upstream E.164;
- бизнес-конфликты через smart process, technical alerts через SigNoz;
- retry horizon 24 часа;
- отсутствие initial backfill;
- отсутствие scheduled full reconciliation в первом релизе.
Остаточные риски:
- организационное разделение test/prod на одном портале не обеспечивается кодом;
- query token может быть виден администраторам портала и в интерфейсе настройки робота; риск снижается разграничением доступа, IP allow-list и ротацией, но не устраняется;
- IP-адреса отправителей Битрикс24 могут измениться без предварительного уведомления; до review allow-list webhook будут отклоняться, а задержка восстановления ограничена interval инкрементальной reconciliation;
- обычный webhook может теряться, поэтому задержка ограничена reconciliation interval;
- ручная перепривязка остаётся ops-действием, хотя ограничена stored procedure;
- отсутствие backfill оставляет часть старых пользователей без CRM mapping - принято, т.к. нет реальных пользователей;
- фактические Bitrix custom field/process IDs появляются только после настройки портала и должны пройти preflight.