SPEC-HUB-0027 §3.2/§12, этап 3. Инварианты включаются на изменении, а не
только на создании: непродуктовый канал больше не может нести checkout,
attribution или обязательную идентичность.
Проверка идёт по итоговому состоянию, а не по переданным полям. Поэтому
снять продукт и выключить коммерческие флаги можно одним запросом, а запрос,
оставляющий канал в запрещённой комбинации, отклоняется целиком — имя из
того же PATCH тоже не сохраняется.
Данные приведены заранее, иначе первый же PATCH имени упёрся бы в ошибку
политики. Отчёт нашёл один нарушитель — непродуктовый канал edevs с
включёнными checkout и attribution. По решению владельца оба флага сняты,
продукт не назначался. Та же миграция чинит дефолты модели, которые сами
нарушали P1-P2: канал создаётся без продукта, а attribution и checkout были
включены по умолчанию.
Нарушения P3-P5 миграция не исправляет, а останавливает выкат с перечнем
каналов: снятие обязательной идентичности меняет смысл канала.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Триггерные функции enforce_tenant_fk/_user/_pair исполнялись как SECURITY
INVOKER, поэтому их внутренний SELECT родительской строки подчинялся RLS.
На кросс-тенантном пути без выставленного custocrm.organization_id
(claim_next_outbox_event берёт событие любой организации) родитель не был
виден, parent_org = NULL, и сравнение IS DISTINCT FROM ложно срабатывало:
легитимный UPDATE падал с `cross-tenant relation on
events_outboxevent.membership_id`.
На проде это увело воркер в краш-петлю (1022 рестарта, ~18 часов простоя):
первое же outbox-событие с membership_id заклинило очередь, а вместе с ней
доставку приглашений на звонок в TG/MAX, поллинг входящих сообщений и
серверные таймауты звонков. Веб-канал не пострадал — там приглашение
проставляется синхронно, минуя outbox.
Проверка не ослабляется: функции исполняются от custocrm_schema, настоящее
нарушение по-прежнему приводит к RAISE. search_path закреплён, как того
требует безопасность SECURITY DEFINER.
Дополнительно claim_next_outbox_event обёрнут в try/except, чтобы
отравленное событие впредь не роняло процесс целиком.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SPEC-HUB-0027 §6-§7, этап 2. Канал создаётся, изменяется, удаляется и
принимает подключения через API — без доступа к серверу и Django admin.
Добавлены детальный GET, POST, расширенный PATCH (продукт, статус, пять
флагов политики), DELETE со списком блокирующих связей, привязка и отвязка
подключений с переносом по force, счётчики за период. Список отдаёт срез по
channels.view: department-scoped сотрудник не видит чужие отделы и не видит
канал без отдела.
Авторизация вынесена в channels/authorization.py: часть операций требует
organization scope, потому что затрагивает коммерческую границу. Смена
отдела, где одна сторона null, тоже требует organization scope — иначе
снятием отдела сотрудник вывел бы канал из собственной видимости.
Инварианты P1-P5 включены на создании. На изменении они остаются
выключенными до этапа 3: в существующих данных есть канал edevs без
продукта с checkout и attribution, и первый же PATCH имени упёрся бы в
ошибку политики (SPEC §12).
conversations/stats.py переведён на channels_in_organization: обзор диалогов
авторизуется собственной capability и не должен фильтроваться по
channels.view.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SPEC-HUB-0027 §5, этап 1. Право разрешить checkout и attribution
отделяется от права настраивать AI: канал — коммерческая граница,
а не настройка агента (ADR-HUB-0037 §9).
Data-миграция выдаёт channels.* профилям с соответствующими ai.*, так что
доступ в момент выката не сужается. Scope сохраняется сам собой: он живёт
на EmployeeAccessAssignment, а capability — на AccessProfile, поэтому
назначения не трогаются вовсе. Обе capability допускают оба scope, так что
allowed_profile_scopes ни одного профиля не сужается.
Откат удаляет выданные строки, и RunPython стоит последним намеренно:
Django откатывает операции в обратном порядке, поэтому строки исчезают
до восстановления прежнего check-constraint реестра.
Эндпоинты каналов пока остаются на ai.*; их переписывает этап 2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Исполнение ADR-HUB-0028 §«Разделение bootstrap-данных»: в облачной модели
арендатора наполняют provisioning и продуктовые API, а не Edevs-specific
команды. Контур был замкнутым островом — единственная точка входа
seed_hub_initial_data, которую не вызывали ни CI, ни compose, ни deploy,
а CLI-тест прямо проверял, что init её не запускает.
Удалены seed_hub_initial_data, _seed_specs, seed_channels, seed_catalog,
seed_orders, seed_support и импортер AI-контента. bootstrap_owner остаётся:
на нём держится тест-сьют. Файлы content/ai-content-*.md сохранены.
Для SPEC-HUB-0027 это снимает половину проблемы этапа 3: унаследованных
seed-каналов, нарушающих P1-P2, больше нет — остаются только дефолты
модели Channel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Канал обработки становится объектом управления: список, карточка,
создание, политика, подключения и счётчики. Разделы AI-агентов и знаний
не затрагиваются — связь с агентом остаётся одной строкой со ссылкой.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Runtime-роль app (CUS_DB_ROLE=app) не имеет прав на django_migrations
(RLS-модель), поэтому migrate в dev-команде backend-app всегда падал с
permission denied и уводил контейнер в crash-loop на свежем томе.
Миграции выполняет one-shot init (CUS_DB_ROLE=migration), от которого
backend-app уже зависит через service_completed_successfully.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sidebar item denotes system settings, not user settings. User
settings stay reachable only via the sidebar user menu. Revert the
nav item to its disabled placeholder state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Rename «Командный центр» → «Обзор», «Уровень компании» → «Управление
компанией», статус «Нормально» → «Штатно», «Открыть отдел» → «Перейти»
- Remove «Инструменты/Доска» sidebar section; enable «Настройки» nav item
- Add dropdown user menu (Профиль/Настройки/Выйти) in all sidebars
- Add /settings route and page; move password, notifications, TOTP and
sessions out of profile, keeping only personal info on the profile page
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Django ORM inserts use INSERT ... RETURNING id, and PostgreSQL applies
SELECT policies to the returned row. The app role could INSERT platform-scope
rows (login-failed audit, platform outbox) via the dedicated INSERT policies
but had no SELECT visibility of them, so the write failed with "new row
violates row-level security policy". This was the true root cause of the
post-cutover login failure that was wrongly remediated with a
custocrm_schema membership grant.
- tenancy.0007: narrow SELECT policies mirroring the existing NULL-org
INSERT policies on identity_auditevent and events_outboxevent
- regression test: app role writes a NULL-org audit event via ORM
- docs: record the 2026-07-17 production remediation (REVOKE applied,
isolation re-verified operationally, second-organization block lifted)
and the discovered ASGI idle-connection pool exhaustion
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Persistent-соединения (CONN_MAX_AGE=60) осиротевали в короткоживущих
потоках asgiref ThreadSensitiveContext: Django закрывает старые соединения
только из потока-владельца, поток умирает после запроса, соединение
остаётся idle навсегда. На prod это дало 85 idle custocrm_app за 4,5 ч и
исчерпание max_connections=100 (500 на /api/v1/auth/login/, 2026-07-17).
Фикс: CONN_MAX_AGE=0 + нативный psycopg pool на процесс (Django 5
OPTIONS["pool"]): на выходе из запроса соединение возвращается в пул
(putconn) независимо от судьбы потока, суммарное число ограничено
max_size. Defaults: min 1 / max 4 / timeout 10s; env-переопределение
CUS_DB_POOL_MIN/MAX/TIMEOUT, CUS_DB_POOL_MAX=0 отключает пул. Worst case
по всем сервисам (app 3w + platform 3w + admin 2w + worker, оба alias)
~72 < 100. В testing пул выключен (pytest-django + TEST MIRROR).
Интеграционная проверка: 60 запросов в короткоживущих потоках ->
ровно 4 серверных соединения. RLS не затронут: tenant-контекст ставится
через set_config(..., local=true) внутри транзакции.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
OpenRouter, the generic Custom adapter and (in track B) CustoAI all speak the
same Chat Completions shape: POST /chat/completions, POST /embeddings,
Authorization: Bearer <key>, response with choices[0].message.content and usage.
The transport and response parsing were duplicated in OpenRouterProvider and
would be duplicated again by CustoAI.
Move post_json / get_json / chat_completions / embeddings into a new
ai/provider/openai_http.py module that owns the HTTP transport and OpenAI-shape
parsing. Adapters keep their product semantics only:
- OpenRouterProvider delegates chat (with usage.include=true to read usage.cost)
and embed to openai_http; it is now a thin adapter, not a transport owner.
- CustomProvider inherits the delegation unchanged.
Update OpenRouterProviderProxyTests to mock openai_http.build_opener (the new
transport location) and add the model field to the fake response so the parsed
ChatResult matches.
Verified: 13/13 integrations tests, 47/47 ai tests.
Add the Custom BYOK integration per ADR-HUB-0034 and SPEC-HUB-0024 §5: an
organization can connect any OpenAI-compatible provider with three fields —
endpoint (Base URL), API key and a free-text model — without a per-provider
adapter. The OpenRouter integration stays a separate ready integration with
its model catalog and is NOT absorbed by generic mode.
Backend:
- integrations/models.py: add CUSTOM to IntegrationProvider + PROVIDER_KIND
(LLM_PROVIDER). TextChoices only affect validation; no DB migration needed
(makemigrations --check: no changes detected).
- integrations/services.py: _normalized_config stores base_url + default_model
for CUSTOM; _CHECKS wires the new check_custom.
- integrations/checks.py: check_custom verifies the endpoint speaks the OpenAI
shape via GET /models with Authorization: Bearer. No catalog authority.
- ai/provider/custom.py: CustomProvider reuses the OpenRouter HTTP layer
verbatim (ADR-HUB-0034 §3) — the contract is identical; only the name differs.
- ai/provider/routing.py: resolve_provider(_and_model) routes through
Channel.provider_integration explicitly (ADR-HUB-0020:45), scoped to that one
integration — never the first org-wide. The integration's default_model
overrides AIAgent.model at runtime, closing the as-built gap where the
«Модель по умолчанию» field was decorative (SPEC-HUB-0005:388, SPEC #2#5).
- ai/provider/factory.py: get_provider(channel) takes an optional channel;
BYOK linked via provider_integration wins over the legacy global path.
- ai/invocation.py: invoke_chat resolves provider+model through routing for a
BYOK channel.
Frontend:
- integrations/model.ts: CUSTOM in the provider type and PROVIDERS map
(hasModel/testable/checkable; secretLabel reuses the existing «API-ключ»).
- integrations/IntegrationForm.tsx: the three Custom fields render through the
existing meta; the OpenRouter-only model placeholder is scoped to OpenRouter.
Tests:
- integrations/tests.py: CustomIntegrationTests (6) — endpoint+model persistence,
free-text model, check_custom openai-shape success/failure.
- ai/tests.py: ByokRoutingTests (7) — CUSTOM→CustomProvider, OpenRouter mapping,
default_model runtime override (SPEC #5), fallback, missing integration,
end-to-end invoke_chat uses the integration model.
Verified: 13/13 integrations tests, 47/47 ai tests, frontend typecheck + vitest.
Acceptance: SPEC-HUB-0024 #1 (Custom with endpoint/key/model), #2 (model field
is read at runtime), #3 (any OpenAI-compatible endpoint), #5 (default_model no
longer decorative). #4 (OpenRouter catalog+check) untouched as-built.
Scope boundary: the CustoAI Managed path, removal of the non-compliant
factory._openrouter_from_integration side-effect token, and the credential_mode
field land in track B (ADR-HUB-0033).
The Hub→CustoCRM rebrand (192497e) missed five user-facing strings and the
matching Playwright selector. The sidebars still offered «Назад в Hub»,
the auth recovery/reset success states still referred to «в Hub», and the
MAX/Telegram notifier hint told staff to open «профиль в Hub».
Replace the last Hub references with the product name CustoCRM (auth,
notifier hint) or drop the qualifier entirely (sidebar back button, which
just returns to the command center). Update the Playwright selector that
asserted the «Назад в Hub» button is absent for operators.
- layout/SalesSidebar.tsx, layout/SupportSidebar.tsx: «Назад в Hub» → «Назад»
- features/auth/AuthPasswordRecovery.tsx, AuthResetPassword.tsx: «в Hub» → «в CustoCRM»
- notifications/binding.py: «профиль в Hub» → «профиль в CustoCRM»
- tests/e2e/internal-ui.spec.ts: selector «Назад в Hub» → «Назад»
The hero background exposed a structural flaw: only the form (.auth-card)
had the white background/border/shadow, while the brand block (.auth-brand)
and the footer note (.auth-note) rendered directly on the image and were
illegible. Move the white surface, border and shadow up to .auth-box so the
brand, form and note form one white card over the background.
Update the login copy for the cloud model (ADR-0031): the app surface is the
Organization Workspace, not the legacy 'internal cabinet' / 'Edevs employees'.
subtitle: 'Рабочая область организации'; note: 'Защищённое соединение'.
Auth screens (login, TOTP, password reset) used a flat #f0f2f5 background. Use
the hero image (resources/hero-v2.png) as a full-bleed cover background, and
deepen the auth-card box-shadow so the form stays legible on top of the image.
C04 RLS relies on the permissive custocrm_schema_access policy (USING/WITH
CHECK true, created by tenancy.0003 on every tenant table) to let login roles
perform cross-tenant writes such as a login-failed audit record with
organization_id IS NULL. That policy is attached to custocrm_schema, but
init-runtime-roles.sh never made custocrm_runtime_app / custocrm_runtime_platform
members of custocrm_schema, so the policy did not cover the login roles and the
write failed with 'violates row-level security policy' even under WITH CHECK
true. Reproduced on a clean DB; the missing membership was the sole cause.
Add the two GRANT statements. Document the bug and the post-cutover CSRF-cookie
fix (62bac47) in INVENTORY-0009 and RUNBOOK-0002.
C01 (settings_app) renamed the CSRF cookie to __Host-custocrm-app-csrf under
SESSION_COOKIE_SECURE, but the API client still read the legacy 'csrftoken'
cookie, so mutating requests (login, etc.) sent an empty/short X-CSRFToken and
got 403. Read the matching cookie name; extract it to a named constant.
C04 added runtime guards to settings_database (distinct DB users) and
settings_storage (S3 bucket required in production). collectstatic runs during
the Docker image build in production mode but never touches the DB or S3, so it
hit these guards and the image build failed.
Pass dummy build-time values for POSTGRES_APP/PLATFORM/MIGRATION_USER and
HUB_S3_BUCKET into the collectstatic RUN step. Runtime values still come from
the instance .env; the guards remain fully enforced in production runtime.
Verified locally: collectstatic completes (163 files).
C07: STARTUP concurrent_p2p_calls switches from a provisional FIXED limit to a
membership-derived source. The limit equals the active non-OWNER, non-blocked
membership count at read time, so adding/removing members auto-adjusts the call
slots without a quota edit. FREE remains FIXED=0.
- QuotaLimitSource.MEMBERSHIP_COUNT + policy derivation (subscriptions.0005)
- plan_models: membership-source grants allow null limit_value
- default_subscription: STARTUP bootstrap uses _ensure_membership_quota
- data migration flips existing STARTUP drafts to MEMBERSHIP_COUNT
- reservation/plans tests cover the derived limit and exclusion semantics
SPEC §10 storage quota: reserve the expected size before the write so a
concurrent upload cannot exceed storage_bytes, finalize with the actual size
after the object is persisted, and release on failure. add_attachment now runs
the full cycle with an idempotent reservation key; reads remain ungated.
- StorageReservation model + OrganizationStorageUsage.reserved_bytes (tenancy.0006)
- tenancy/storage_quota.py: reserve_storage/finalize_storage/release_storage
- ai/services.add_attachment: pre-write reserve, release-on-failure, finalize
Coverage-matrix operation x entitlement/quota x transport in INVENTORY-CUSTOCRM-0009.
Enforcement infrastructure:
- HasEntitlement DRF permission (org-level feature gate, orthogonal to
HasCapability); centralized api_exception_handler mapping
EntitlementRequired->403, QuotaExceeded HARD->409 / RATE+CONCURRENT->429
(+Retry-After), PolicyUnavailable->503, UsageConflict->409.
- quota_service.check() advisory pre-check.
- UsageReservation lease model + reserve_usage/release_usage/
expire_stale_reservations for CONCURRENT quotas (SPEC-HUB-0022 section 7);
worker sweep reaps lapsed leases. QuotaExceeded now carries mode.
Cumulative quota gates (record_usage, mirror ai_agent_slots):
- products (create_product), client_connections (create_integration),
new_dialogs_per_period (new dialog only, is_new branch in ingest + support),
managed_ai_credits (successful LlmInvocation, best-effort accounting).
Storage gate: assert_storage_quota pre-write check blocks overage on writes,
reads never gated (PLAN section 12).
Concurrent p2p_calls: reserve on CallSession create, release on terminal
transition in calls/lifecycle.py.
Entitlement gates on views: sales_department, support_department,
product_sales_api (inline), p2p_calls, knowledge_base, managed_ai
(inline handoff on deny).
Deferred (no target module): crm_api, voice_ai, sso_saml,
concurrent_voice_sessions, customer_audit (tracks B/C/C12).
bootstrap_edevs_owner now provisions an active STARTUP subscription
(ensure_default_subscription) since post-C07 every tenant operation requires
a subscription; create_test_subscription reconciles ai_agent_slot counter for
tests that create active agents directly.
Gate (PLAN section 12): coverage-matrix exists; storage overage blocks writes
not reads; downgrade/suspension zero-limit clears entitlements without delete;
Free counts only a brand-new dialog.
Targeted tests: reservations 5, quota_service 4, enforcement mapping 6.
Full backend regression green except one pre-existing SOCKS5 test and one
pre-existing membership-migration ProtectedError (both fail on clean HEAD).
UI unchanged. Production migration/deploy and publication of production
PlanVersion are not part of this commit.
Closes the C06 production prerequisites by providing explicit, runnable
instruments whose execution stays with the owner.
- publish_plan_versions: publishes the current draft PlanVersion for the
saleable plans (Free, Startup). Idempotent; Business/Corporation stay
draft. Once published, a version and its grants are immutable at model
and PostgreSQL trigger level.
- create_platform_token: creates a PlatformOperator and an opaque API
token. The plaintext token is printed ONCE; only its sha256 hash is
stored. Rejects unknown capability codes. Credential operation - run
only by the owner, transfer the plaintext to a secret store immediately.
Running these commands in production is not part of this commit and
requires explicit owner confirmation.
Single idempotent write boundary provision_organization coordinates
Organization, OWNER membership or invitation, subscription/usage,
system departments sales/support, audit and transactional outbox in one
transaction under set_local_tenant.
- New hub_platform.platform domain: PlatformOperator, hash-only
PlatformToken, OrganizationProvisioning process record with state
machine PENDING/IN_PROGRESS/WAITING_FOR_OWNER/COMPLETED/FAILED.
- Platform API POST platform.custocrm.ru/api/v1/organizations: machine
token auth + capability platform.organizations.provision, Idempotency-Key
header, replay/conflict handling, no secrets in response.
- OWNER invitation accept (accept_invitation + auth view) activates
organization, subscription and first usage period; idempotent re-accept
does not duplicate membership or period.
- DB write boundary (tenancy/0005): platform INSERT/UPDATE on
identity_organization and platform DML + tenant-isolation policy on
identity_department/identity_employeeprofile; Organization.status
ACTIVE/PENDING_OWNER (identity/0015).
- System department codes centralised in identity/system_departments.
Machine token without MFA is an owner-approved deviation from
ADR-HUB-0031 section 9; human+MFA platform login belongs to track D.
concurrent_p2p_calls derived from membership count is a separate C07
decision (PLAN-CUSTOCRM-0003 section 12).
Gate: idempotent replay creates no second tenant; operator never becomes
OWNER; provisioning creates no AI-agent/product/connection/demo data.
Full backend regression 379 passed (one pre-existing unrelated SOCKS5
test); 19 C06 targeted tests passed. UI unchanged. Production
migration/deploy, publication of production PlanVersion and bootstrap of
the production platform token are not part of this commit.
Add owner-approved (2026-07-15) quota values for Free/Startup plan drafts
required before C06 provisioning: managed_ai_credits, storage_bytes,
crm_api_requests_per_window, concurrent_p2p_calls, concurrent_voice_sessions.
Refactor PLAN_SPECS to a unified quotas dict supporting HARD/RATE/
CONCURRENT/UNLIMITED modes. Business/Corporation remain saleable=false
drafts with only ai_agent_slots.
concurrent_p2p_calls (Startup=3) is a provisional minimum; deriving it
from active membership count is a separate C07 decision.
Plan versions remain unpublished; production migration/deploy and
publication are not part of this commit.