Files

816 lines
57 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# module-07. Полная спецификация `bitrix-sync`
> Статус: целевая постановка первого функционального релиза CRM-синхронизации.
> Заменяет прежнюю спецификацию DB-connectivity stub.
> Исторический, неканонический концепт: [`archive/sync-service-concept.md`](../../archive/sync-service-concept.md); для реализации использовать только настоящий документ и architectory.
> Связанные контракты: [`arch-01-system-architecture.md`](../../architectory/arch-01-system-architecture.md), [`arch-02-api-contracts.md`](../../architectory/arch-02-api-contracts.md), [`module-01-api-backend.md`](../../VM1_app/documentation/module-01-api-backend.md), [`module-10-deployment-vm2.md`](module-10-deployment-vm2.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_<latin letters or digits>` детерминированно преобразуется в REST-имя `ufCrm_<latin letters or digits>`. В частности, `BITRIX_SYNC_CONTACT_REGISTERED_FIELD=UF_CRM_6a70c275346a7` используется в `crm.item.list` как `ufCrm_6a70c275346a7`. Произвольное изменение регистра или иной алгоритм преобразования запрещены; обе формы валидируются на 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://<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:
```text
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`:
```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_CONTACT_SOURCE=<Bitrix source ID>
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.