Files
han-app/modules/module-07-bitrix-sync.md
T

57 KiB
Raw Blame History

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. Принципы

  1. PostgreSQL — source of truth очереди, workflow и mapping; оперативная память не хранит единственное состояние сценария.
  2. Delivery semantics — at-least-once. Идемпотентность обязательна для каждой внешней операции.
  3. Сценарий хранится как state machine, а не как заранее созданный произвольный DAG атомарных команд.
  4. han_app.sync_queue содержит бизнес-намерения App; bitrix_sync.crm_commands содержит конкретные вызовы CRM.
  5. Один CRM-command соответствует одному подзапросу batch.
  6. Телефон используется для поиска только при первичном создании связи.
  7. После формирования связи Contact читается и обновляется только по b24_id.
  8. Запрашиваются только необходимые поля.
  9. Business conflicts и technical failures имеют разные журналы и каналы эскалации.
  10. Секреты и PII не записываются в payload очередей, логи, traces и метрики.
  11. 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 CRM active кодируется как 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 содержит UUID UserIdentity.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.

Общий порядок:

  1. Получить актуальные UserIdentity и ClientProfile.
  2. Проверить active mapping.
  3. При отсутствии mapping выполнить crm.duplicate.findbycomm по телефону.
  4. Получить только поля найденных Contact: ID, NAME, PHONE, EMAIL, citizenship, user_id, registration flag, CREATED_TIME и source update timestamp.
  5. Если среди результатов есть Contact с тем же user_id, восстановить локальный mapping без повторного create, обновить registration flag только при расхождении и перейти к шагу 9.
  6. Применить ветку В0–В2 (если на предыдущем шаге не завершен сценарий).
  7. Обновить Contact.
  8. Создать active mapping в bitrix_sync.
  9. Для существующего/восстановленного Contact записать его CRM-master поля в App с SET LOCAL han.sync_suppress='true' и сохранить source snapshot; для созданного Contact сохранить snapshot фактически записанных значений.
  10. При наличии конфликта создать/обновить business alert.
  11. Завершить workflow и исходную queue task.

Ветки:

  • В0, Contact не найден: создать Contact с телефоном, доступными начальными значениями, user_id, registration flag active; после ambiguous timeout повторить поиск по телефону и не повторять create вслепую.
  • В1.1, один Contact без active связи: записать user_id, flag active, создать 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/update not found пометить mapping broken, сохранить последний 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
)

Процедура:

  1. блокирует текущий active mapping пользователя;
  2. проверяет отсутствие active mapping целевого b24_id с другим пользователем;
  3. создаёт rebind_request и workflow contact.rebind;
  4. не изменяет действующий mapping до завершения CRM-команд.

Worker:

  1. читает прежний и целевой Contact по ID;
  2. повторно проверяет, что целевой Contact существует и не содержит user_id другого active пользователя;
  3. записывает в целевой Contact текущий user_id и registration flag active;
  4. очищает user_id и устанавливает flag inactive в прежнем Contact, только если там всё ещё находится ожидаемый user_id;
  5. после успеха обеих CRM-команд одной транзакцией закрывает старый mapping, создаёт новый active mapping и завершает rebind_request;
  6. завершает связанный business alert только после успешного изменения mapping.

Если обновление целевого Contact прошло, а очистка прежнего завершилась временной ошибкой, workflow остаётся waiting_retry: новый mapping ещё не активируется, повторная команда проверяет текущее значение и безопасно продолжает сценарий. Все шаги идемпотентны.

5.5. Bitrix24 → App

  1. Webhook принимается и durable сохраняется в webhook_inbox.
  2. Несколько необработанных событий одного Contact coalesce в одно чтение.
  3. По b24_id ищется active mapping.
  4. Contact запрашивается по ID с минимальным select.
  5. Проверяются user_id и registration flag.
  6. CRM-master поля записываются в App одной транзакцией с SET LOCAL han.sync_suppress='true'.
  7. Обновляются 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 мигрируется:

  1. создать целевую таблицу и ограничения в bitrix_sync;
  2. перенести и проверить существующие связи;
  3. переключить worker/repositories;
  4. удалить GRANT и зависимости App от старой таблицы;
  5. удалить han_app.entity_external_mapping;
  6. отдельной 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 2 HTTP 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:

  1. сравнить время всплеска с webhook_rejected_total{reason="source_ip"} и безопасным журналом отклонённых source IP;
  2. проверить, что новый адрес действительно принадлежит инфраструктуре Битрикс24/портала, используя согласованный канал или контролируемый probe; одного факта запроса с корректным token недостаточно;
  3. при подтверждении изменить version-controlled allow-list CIDR, пройти review и применить конфигурацию nginx;
  4. убедиться, что 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.

Одноразовая процедура:

  1. Остановить старый stub/worker.
  2. Применить migrations и grants.
  3. Зафиксировать cutover_watermark.
  4. Перевести все существовавшие до watermark pending/retry contact-задачи в cancelled с причиной initial_full_sync_cutover; слово full здесь относится к переходу со stub на full mode, а не к reconciliation.
  5. Не создавать backfill для active пользователей без mapping.
  6. Проверить env/secrets/settings/fields/rights/webhook.
  7. Запустить сервис disabled и выполнить preflight.
  8. Включить обработку только задач после watermark.
  9. Наблюдать 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.rebind durable и идемпотентны;
  • 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.