# module-07. Полная спецификация `bitrix-sync` > Статус: целевая постановка первого функционального релиза CRM-синхронизации. > Заменяет прежнюю спецификацию DB-connectivity stub. > Исходный концепт: [`sync-service-concept.md`](sync-service-concept.md). > Связанные контракты: [`../architectory/arch-01-system-architecture.md`](../architectory/arch-01-system-architecture.md), [`../architectory/arch-02-api-contracts.md`](../architectory/arch-02-api-contracts.md), [`module-01-api-backend.md`](module-01-api-backend.md), [`module-10-deployment-runbook.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: ```text 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_` детерминированно преобразуется в REST-имя `ufCrm_`. В частности, `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: ```text 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` Существующая таблица мигрируется без потери записей. Требуемые поля: ```text 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 ``` Допустимые статусы: ```text 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: ```json { "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`: ```text 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 ```text created -> running -> waiting_crm -> waiting_retry -> waiting_manual -> succeeded | failed | cancelled ``` Только persisted transition активирует следующий шаг. Один workflow имеет не более одной активной команды, кроме явно независимых операций alert/status. ### 7.2. CRM command ```text 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 ```text 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. Адреса приёма ```text Contact receiver: https:///bitrix/sync/webhook/contact?token= Alert receiver: https:///bitrix/sync/webhook/alert?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: ```text POST ?token=&ID= Content-Type: application/x-www-form-urlencoded document_id[0]=crm document_id[1]=CCrmDocumentContact document_id[2]=CONTACT_ auth[domain]= auth[client_endpoint]=https:///rest/ auth[server_endpoint]=https://oauth.bitrix24.tech/rest/ auth[member_id]= ``` Для alert `document_id[1]` содержит тип dynamic document, а `document_id[2]=DYNAMIC__`. Значение 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` и `: 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`: ```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: ```text 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 ```text 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= BITRIX_SYNC_PORTAL_MEMBER_ID= BITRIX_SYNC_PUBLIC_BASE_URL=https:// BITRIX_WEBHOOK_ALLOWED_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.