LLD — низкоуровневый дизайн
LLD (Low-Level Design) — низкоуровневый (детальный) архитектурный дизайн. Описывает, как реализуется то, что зафиксировано в BRD: декомпозиция системы по уровням C4 (контейнеры, компоненты), сценарии взаимодействия (sequence) и спецификации внешних API. LLD — рабочая карта для команды разработки; при расхождении с BRD приоритет у BRD.
Проект: Web Shield AI Версия: 0.1 (черновик) Базовый документ: BRD Ответственный: А. Зубик (архитектор)
1. Цель и подход
Заголовок раздела «1. Цель и подход»Многоуровневое проектирование сервиса Web Shield AI от контейнерной диаграммы (C2) до спецификации внешнего API.
Скоуп этого документа. Контейнеры Verdict API и Verdict Cache (Redis) показаны на C2 как design-задел (пунктирные интеграционные точки). По решению заказчика Verdict API в работу не берём — он оставлен в дизайне как зафиксированный контракт на будущее, на случай интеграции с PSP-шлюзом. В текущей реализации hits и вердикты пишутся в PostgreSQL, потребитель — только UI комплаенс-офицера и Reporter. Цель раздела — зафиксировать границы ответственности и точки интеграции так, чтобы при возможном расширении модель данных Scanner Engine не пришлось перекраивать.
Актуализация под реализацию. Диаграммы ниже сохраняют исходный проектный замысел (self-hosted LLM, vision/OCR, SSO). Фактически реализовано иначе: AI-слой — LLM-классификация и семантика через эмбеддинги облачного провайдера Mistral (+ vector DB Qdrant); OCR/vision и SSO вне области (см. BRD и RTM). Контейнеры читать с этой поправкой.
Принципы (унаследованы из практики lesson-05-lld):
- Contract First. Сначала фиксируется OpenAPI на внешнюю границу — Verdict API. Контракт страхует интеграционные риски с платёжным шлюзом, где SLA жёстче всего (p95 ≤ 200 мс).
- C4-уровни, по одному на диаграмму. Одна абстракция на диаграмму — без «слоёного пирога».
- Стрелки = глагол + технология. Каждая связь подписана действием и транспортом (HTTPS/JSON, gRPC, S3, AMQP и т.п.).
- High Cohesion + Low Coupling. Crawler / Scanner / AI Verifier — отдельные сервисы; AI-слой не знает деталей краула, общается через очередь и кандидатов.
- Резилентность — в LLD. Circuit Breaker / Retries / Timeouts на внешних клиентах (LLM, vision, headless). Деградация краула не должна влиять на доступность Verdict API (NFR-Availability).
- Single source of truth. Один владелец на одно правило (тариф проверяет Backend на инициации, не Scanner на каждой странице).
2. C2 — Container Diagram
Заголовок раздела «2. C2 — Container Diagram»flowchart LR
officer([Комплаенс-офицер])
admin([Администратор сервиса])
gw[[Платёжный шлюз / PSP]]
merchant[[Сайт мерчанта]]
sso[[Keycloak / Корпоративный SSO]]
llm[[LLM Provider · Qwen self-hosted]]
vision[[Vision / OCR Provider]]
smtp[[SMTP / Webhook receiver]]
subgraph SH[Web Shield AI]
ui[Admin Web UI<br/>React / TypeScript]
api[Backend / Control Plane<br/>Python · FastAPI]
verdict[Verdict API · фаза 3<br/>Python · FastAPI · stateless]
sched[Crawl Scheduler<br/>Celery beat · окно рекраула по тарифу]
crawler[Crawler Worker<br/>httpx · respect robots.txt]
headless[Headless Renderer<br/>Playwright · JS fallback FR-06]
scanner[Scanner Engine<br/>Aho-Corasick + pymorphy3 ru/be]
ai[AI Verifier · Premium<br/>LLM / embeddings / vision]
reporter[Reporter<br/>PDF · история · экспорт]
bus[(Message Bus<br/>RabbitMQ / Redis Streams)]
pg[(SQL DB · PostgreSQL<br/>tenants · sites · hits · verdicts · audit)]
qdr[(Vector DB · Qdrant<br/>embeddings стоп-фраз · Premium)]
s3[(Object Storage · S3<br/>HTML-снимки · скриншоты · immutable)]
cache[(Verdict Cache · Redis · фаза 3<br/>verdict by domain/URL · TTL=recrawl_interval)]
end
officer --> ui
admin --> ui
ui -->|REST · HTTPS/JSON| api
ui -->|OIDC| sso
api --> sso
gw -. "фаза 3: GET /v1/verdict · HTTPS/JSON · JWT" .-> verdict
verdict -. фаза 3 .-> cache
verdict -. фаза 3 · miss .-> pg
api --> pg
api --> sched
sched -->|enqueue crawl job| bus
bus --> crawler
crawler -->|HTTPS GET| merchant
crawler -.->|JS-heavy → fallback| headless
headless -->|HTTPS GET| merchant
crawler -->|publish page| bus
bus --> scanner
scanner --> pg
scanner --> s3
scanner -. Premium candidates .-> bus
bus --> ai
ai -->|HTTPS/JSON| llm
ai -->|HTTPS/JSON| vision
ai --> qdr
ai --> pg
scanner -. "фаза 3: invalidate" .-> cache
ai -. "фаза 3: invalidate" .-> cache
api -->|alerts FR-17| smtp
reporter --> pg
reporter --> s3
reporter -->|PDF FR-19| officer
Почему такой набор контейнеров
Заголовок раздела «Почему такой набор контейнеров»| Контейнер | Зачем | Почему отдельный (deploy-критерий) |
|---|---|---|
| Admin Web UI | Управление сайтами, стоп-листами, разметка срабатываний (FR-03, FR-04, FR-18) | React SPA, отдельный CDN/static, релизится чаще API |
| Backend / Control Plane | Multi-tenant CRUD, тарифы (FR-02), управление расписанием, алертинг (FR-17) | Бизнес-правила и SSO; не должен страдать от нагрузки краула |
| Verdict API (фаза 3) | Низколатентный (p95 ≤ 200 мс) вердикт по URL/домену для платёжного шлюза (FR-15, FR-16) | Отдельный сервис из-за SLA: деградация краула не имеет права валить hot path оплаты (NFR-Availability ≥ 99.5%). В MVP не реализуется — контракт зафиксирован заранее, см. раздел 5 |
| Crawl Scheduler | Очередь рекраула, окна и лимиты по тарифу (FR-13, FR-02) | Workload-pattern «фоновые задачи + cron», изолируется от API |
| Crawler Worker | HTTP-обход страниц, robots.txt, троттлинг по домену (FR-05) | I/O-bound, скейлится по входной очереди, отдельный namespace |
| Headless Renderer | JS-рендеринг страниц, где статический обход вернул мало текста (FR-06) | Тяжёлые ресурсы (Chromium); только Premium / по необходимости |
| Scanner Engine | Извлечение текста, морфологический матчинг ru/be, анти-обфускация (FR-07, FR-08, FR-09) | Ядро MVP; CPU-bound, скейлится отдельно. Раскрыт в разделе 3 |
| AI Verifier (Premium) | LLM-классификация (FR-10), семантический поиск (FR-21), OCR/vision (FR-11) | GPU-нода / дорогой downstream; получает только кандидатов, не сырые страницы (инвариант экономики Премиума) |
| Reporter | Daily/ondemand PDF-отчёты, история (FR-14, FR-19, FR-20) | CronJob, batch-workload |
| Message Bus | Развязка Crawler → Scanner → AI Verifier; backpressure, retries | Любой временный лаг AI не блокирует краул |
| SQL DB (PostgreSQL) | Тенанты, сайты, стоп-листы, hits, verdicts, audit log | Managed-сервис, HA, бэкапы |
| Vector DB (Qdrant) | Эмбеддинги стоп-фраз и страниц для семантики (FR-21, Premium) | Специализированное хранилище, только Premium |
| Object Storage (S3) | HTML-снимки и скриншоты — immutable, append-only с TTL по политике (FR-12) | Lifecycle-rules, дешёвое хранение |
| Verdict Cache (Redis) (фаза 3) | Кэш вердиктов по домену/URL для p95 ≤ 200 мс | Без кэша Verdict API не уложится в SLA. В MVP не нужен — потребитель один (UI), читает напрямую из PG |
Архитектурные инварианты, отражённые на C2
Заголовок раздела «Архитектурные инварианты, отражённые на C2»- Verdict API не запускает краул. Только чтение кэша, при miss — fallback в PG. Никаких синхронных вызовов в Scanner/AI.
- Двухступенчатый пайплайн. Crawler → Scanner (дешёвый детерминированный проход) → опционально AI Verifier (для Premium, по кандидатам). Не инвертировать.
- Один владелец на тариф. Лимиты (частота, глубина, число сайтов) применяет Crawl Scheduler на инициации. Scanner и AI Verifier не валидируют тариф на каждой странице.
- Immutable evidence. S3-снимки пишутся append-only и привязаны к
scan_id— это юридическая защита перед регулятором / платёжной системой.
3. C3 — Components внутри Scanner Engine
Заголовок раздела «3. C3 — Components внутри Scanner Engine»flowchart LR
bus[[Message Bus<br/>page.ready]]
pg[(SQL DB)]
s3[(Object Storage)]
cache[(Verdict Cache)]
busOut[[Message Bus<br/>candidate.ready · Premium]]
subgraph SE[Scanner Engine]
ctrl[Consumer<br/>aiopika · ack only after persist]
extr[Text Extractor<br/>trafilatura · отсечь nav/menu/footer · FR-07]
norm[Normalizer<br/>casefold · NFC · diacritics · FR-08]
deob[Anti-obfuscation Layer<br/>гомоглифы · разрядка · транслит · FR-09]
morph[Morphology Engine<br/>pymorphy3 ru/be · lemma index]
ac[Aho-Corasick Matcher<br/>per-tenant automaton · FR-08]
agg[Candidate Aggregator<br/>группировка hits по странице + категория]
evi[Evidence Writer<br/>HTML+screenshot ref · immutable · FR-12]
persist[Verdict Updater<br/>upsert hits · пересчёт verdict · BR]
invalidator[Cache Invalidator<br/>домен/URL keys]
premium{{Premium?<br/>tenant.tier == premium}}
end
bus --> ctrl
ctrl --> extr --> norm --> deob --> morph --> ac --> agg
agg --> evi --> s3
agg --> persist --> pg
persist --> invalidator --> cache
agg --> premium
premium -->|yes · только кандидаты| busOut
premium -->|no · стоп| persist
Компоненты — назначение и границы
Заголовок раздела «Компоненты — назначение и границы»| Компонент | Назначение | Граница ответственности |
|---|---|---|
| Consumer | Чтение page.ready из шины, ack только после записи в PG/S3 | Не знает деталей матчинга; гарантирует at-least-once |
| Text Extractor | Извлекает значимый текст (статья, описание товара), отсекает nav/menu/footer (FR-07) | Не делает нормализацию и не классифицирует |
| Normalizer | casefold, NFC, удаление диакритики, нормализация пробелов | Чистый функционал, без I/O |
| Anti-obfuscation Layer | Разрядка букв («з а п р е щ»), гомоглифы (кириллица/латиница), транслит (FR-09). На Basic — базовая, на Premium — продвинутая | Изолирует «магию» — единственное место в Scanner, чувствительное к подписке |
| Morphology Engine | pymorphy3 для ru/be: лемматизация текста и стоп-фраз (FR-08) | Не лезет в матчинг; отдаёт лемматизированную форму |
| Aho-Corasick Matcher | Один автомат на тенанта, перестраивается при изменении стоп-листа | Только точные/морфо-совпадения; семантика — не сюда |
| Candidate Aggregator | Группирует hits по странице, добавляет категорию нарушения | Решает, что считается «кандидатом» для Premium-пайплайна |
| Evidence Writer | Сохраняет HTML-снимок и ссылку на скриншот в S3 (FR-12); пишет immutable record | Один раз, без перезаписи |
| Verdict Updater | Upsert hits в PG, пересчёт site.verdict по бизнес-правилу: «1+ подтверждённый prohibited → non-compliant» | Учитывает FR-18 (ложные не считаются) |
| Cache Invalidator | Сбрасывает ключи Verdict Cache по домену и URL | Единственная запись в Redis из Scanner — иначе race с AI Verifier |
| Premium-gate | Решает, отправлять ли кандидатов в AI Verifier по tenant.tier | Тариф проверяется здесь, не на каждой странице до этого |
Почему такая декомпозиция
Заголовок раздела «Почему такая декомпозиция»- Анти-обфускация выделена отдельным слоем — её сложность (юникод-классы, гомоглифы) изолирована от матчера; матчер видит «чистый» нормализованный поток.
- Aho-Corasick + морфология — это и есть тот «дешёвый первый проход», который выполняется для обоих тарифов. Все дорогие модели работают только по выходу
Candidate Aggregator. - Evidence Writer перед Verdict Updater — порядок специально такой: сначала фиксируем доказательство (S3 immutable), потом отмечаем hit в PG. Это даёт юридическую гарантию: каждый зафиксированный hit имеет привязанное доказательство.
- Cache Invalidator только в Scanner — даже если AI Verifier потом понизит уверенность hit’а, инвалидация уже сделана; Verdict API увидит обновлённое состояние при следующем чтении.
4. Sequence — «Платёжный шлюз спрашивает вердикт» (FR-15, FR-16) (фаза 3)
Заголовок раздела «4. Sequence — «Платёжный шлюз спрашивает вердикт» (FR-15, FR-16) (фаза 3)»Hot path при оплате: SLA p95 ≤ 200 мс, не вызывает live-краул, читает только закэшированный вердикт. Не входит в MVP — сценарий зафиксирован для фиксации интеграционного контракта с PSP-шлюзом.
sequenceDiagram
autonumber
actor B as Покупатель
participant GW as Платёжный шлюз
participant VA as Verdict API
participant RC as Verdict Cache (Redis)
participant PG as SQL DB
participant L as Audit log
B->>GW: Pay (merchant_id, URL)
GW->>VA: GET /v1/verdict?domain=…&url=… (JWT)
VA->>VA: validate JWT, tenant scope
VA->>RC: GET verdict:{tenant}:{url_hash}
alt cache hit (горячий путь)
RC-->>VA: {status, confidence, scanned_at}
VA-->>GW: 200 OK · verdict
else cache miss (cold)
RC-->>VA: nil
VA->>PG: SELECT verdict FROM site WHERE … (read replica)
PG-->>VA: row | not_found
alt found
VA->>RC: SETEX verdict:… TTL=recrawl_interval
VA-->>GW: 200 OK · verdict
else not_found
VA-->>GW: 200 OK · {status: "not_monitored"}
end
end
Note over VA,L: Async — никогда в hot path
VA-)L: append audit (tenant, url, decision, latency_ms)
GW->>GW: if status=="warning" → show banner / require 2FA
GW->>GW: if status=="block" → reject transaction
GW-->>B: UI: continue / warning / blocked
Связность C2/C3 ↔ Sequence (критерий приёмки):
| Шаг | Контейнер / компонент |
|---|---|
| 1–2 | Платёжный шлюз → Verdict API (внешняя граница) |
| 3 | Verdict API: JWT + tenant scope |
| 4–5 | Verdict Cache (Redis) — горячий путь |
| 6–9 | SQL DB read replica + populate cache (cold path) |
| 10 | Audit log — async, не в hot path |
| 11–13 | Решение шлюза по status (clean / warning / block) |
Альтернативный sequence — Premium handoff
Заголовок раздела «Альтернативный sequence — Premium handoff»После того как Scanner Engine нашёл кандидата, дальнейшая судьба зависит от тарифа:
- Basic. Аggregator сразу зовёт Verdict Updater → hit фиксируется как
prohibited.unverified. Вердикт сайта — бинарный. - Premium. Aggregator публикует
candidate.readyв шину; AI Verifier (LLM + embeddings + vision) ставитconfidence. Только после ответа Verifier’а пишется итоговый hit сconfidenceи категорией; Cache Invalidator срабатывает в обоих случаях.
Это прямое следствие инварианта «двухступенчатый пайплайн» из BRD: дорогие модели не гоняются по всем страницам.
5. OpenAPI — Verdict API (фрагмент) (фаза 3)
Заголовок раздела «5. OpenAPI — Verdict API (фрагмент) (фаза 3)»Контракт фиксируется заранее (Contract First), чтобы при интеграции с банком-эквайером не пришлось перекраивать модель данных Scanner Engine. В фазе 1 (MVP) этот сервис не разворачивается. Полная спецификация — artifacts/verdict-openapi.yaml (TODO, при выходе на фазу 3). Ключевые решения:
openapi: 3.1.0info: title: Shield Verdict API version: 1.0.0 description: | Низколатентный вердикт комплаенса по домену/URL мерчанта для встраивания в платёжный поток (FR-15, FR-16). SLA: p95 ≤ 200 мс.servers: - url: https://verdict.shield.example/v1
paths: /verdict: get: operationId: getVerdict summary: Получить вердикт по домену или конкретному URL security: [{ bearerAuth: [] }] parameters: - { in: query, name: domain, schema: { type: string }, required: true, description: "ASCII/IDN-домен мерчанта; нижний регистр" } - { in: query, name: url, schema: { type: string, format: uri }, required: false, description: "Конкретный URL страницы; если опущен — вердикт по домену" } responses: '200': description: Вердикт получен (включая not_monitored — это валидный исход, не ошибка) content: application/json: schema: { $ref: '#/components/schemas/Verdict' } examples: clean: value: status: clean scanned_at: "2026-05-27T18:42:11Z" tier: basic warning_premium: value: status: warning confidence: 0.83 categories: ["prohibited.tobacco"] scanned_at: "2026-05-28T03:11:00Z" tier: premium evidence_ref: "s3://shield-evidence/2026/05/28/abc123/" not_monitored: value: { status: not_monitored } '401': { $ref: '#/components/responses/Unauthorized' } '429': { $ref: '#/components/responses/RateLimited' } '503': { $ref: '#/components/responses/UpstreamUnavailable' }
components: securitySchemes: bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
schemas: Verdict: type: object required: [status] properties: status: type: string enum: [clean, warning, block, not_monitored, needs_attention] confidence: type: number minimum: 0 maximum: 1 description: "Только для Premium-тенанта; для Basic вердикт бинарный (BR из раздела 5 BRD)." categories: type: array items: { type: string } scanned_at: { type: string, format: date-time } tier: { type: string, enum: [basic, premium] } evidence_ref: type: string description: "Ссылка на immutable доказательства (FR-12), только при status=warning|block."
responses: Unauthorized: { description: JWT отсутствует/невалиден, content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } } RateLimited: description: Лимит запросов по тенанту/тарифу превышен headers: Retry-After: { schema: { type: integer }, description: "Секунды до следующей попытки" } content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } UpstreamUnavailable: description: Кэш и БД одновременно недоступны (Circuit Breaker open) content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }Ключевые решения и их обоснование
Заголовок раздела «Ключевые решения и их обоснование»not_monitored— это200 OK, не404. Платёжный шлюз спрашивает «есть ли у тебя данные по этому мерчанту»; отсутствие — валидный бизнес-исход, обрабатывается тем же путём, что иclean. (Параллель с lesson-05:unrecognizedинтент тоже остался в200, а не уходил в422.) HTTP-статус описывает судьбу HTTP-обмена, а не бизнес-исход.confidenceтолько для Premium. Бизнес-правило BR из BRD: Basic — бинарный вердикт; Premium — с уверенностью. В контракте это выраженоnullable-полем + комментарием.evidence_refуказывается только приwarning/block. Это прямая привязка к FR-12 (доказательства) и юридическая прослеживаемость.503явный. Когда Redis и реплика PG разом недоступны — Circuit Breaker open, шлюз должен переключиться на свой fallback (например, всегдаwarning). Лучше явный503, чем медленный таймаут на 200 мс SLA.Retry-Afterв429. Лимиты по тарифу: жадный клиент должен знать, когда повторять.- Версионирование URI (
/v1/...). Verdict API — внешний контракт, ломающие изменения требуют новой версии в URL. - Security — Bearer JWT с tenant scope. Изоляция тенантов на уровне токена; Verdict API проверяет
scope == domain.owner_tenant.
6. Соответствие критериям многоуровневого проектирования
Заголовок раздела «6. Соответствие критериям многоуровневого проектирования»- Соответствие нотации C4. C2 — контейнеры, C3 — компоненты внутри Scanner Engine; обе диаграммы Mermaid, связи направлены, на каждой стрелке указан транспорт.
- Связность C3 ↔ Sequence. Hot path (Verdict API) использует Verdict Cache → SQL DB → audit. Scanner-пайплайн привязан к шагам Premium handoff. Каждый шаг sequence ссылается на свой компонент или контейнер.
- Качество API. Спецификация содержит схему
Verdict, три примера ответа (clean,warning_premium,not_monitored), коды ошибок401/429/503с RFC 7807 (application/problem+json), заголовокRetry-After, security-схему JWT.
7. Открытые вопросы / TODO
Заголовок раздела «7. Открытые вопросы / TODO»- TLS-mTLS со шлюзом vs JWT-only. При входе банка-эквайера может потребоваться mTLS поверх JWT — решается на этапе интеграции.
- Webhook от Backend в шлюз для push-инвалидации кэша при срочном изменении вердикта — фаза 2.
- OCR-конвейер. Картинки идут через Crawler → Scanner.ImageRefExtractor → AI Verifier.Vision. Описывается отдельно при выходе фазы 2.
- Стратегия миграции стоп-листов при обновлении (rebuild автомата без даунтайма). Решается в HLD реализации Scanner.
8. Расширение продукта: домен InvestiGate + Onboarding
Заголовок раздела «8. Расширение продукта: домен InvestiGate + Onboarding»Модули Onboarding и InvestiGate реализованы в admin (контроль-плейн), без отдельных
сервисов и без изменения контракта со сканером (admin↔scanner — прежний Scanner API). Подробный
план и состав — admin/ROADMAP-modules.md. Доменная модель (схемой владеет admin, миграции):
classDiagram Merchant "1" --> "*" Site : websites Merchant "1" --> "*" MerchantPerson : директора/UBO Merchant "1" --> "*" MerchantAddress Merchant "1" --> "*" MerchantDocument Merchant "1" --> "*" InvestigateCase : кейсы InvestigateCase "1" --> "*" CaseIndicator : 49 индикаторов InvestigateCase "1" --> "1" CremAssessment : CREM InvestigateCase "1" --> "*" CaseRecommendation : checklist Application ..> Merchant : convert() Application ..> InvestigateCase : convert() IndicatorDefinition ..> CaseIndicator : каталог (snapshot) MccReference ..> InvestigateCase : регуляторика по MCC
Поток: Application (заявка) →convert()→ Merchant (+структура) + InvestigateCase
(на created: наполнение индикаторами каталога, авто-деривация контент-индикаторов из сканера/MCC,
расчёт risk score, генерация recommendation checklist) → разметка офицером → решение Accept/Decline
(проекция на Merchant.lifecycle_status) → PDF-отчёт.
Risk score — вычитательная модель (RiskScoreCalculator): старт 100%, вычеты по сработавшим
индикаторам (red/amber) и за непроведённое ревью обязательных, пол 1%; уровень LOW/MEDIUM/HIGH.
CREM (CremCalculator) — кредитная экспозиция (FEaD/EtPR/сценарии/залоги), чистые формулы.
Разделение модулей в UI — nav-группами одной панели (не отдельные панели — см. ROADMAP «Упаковка»).
Каналы intake Application (FR-30/31): ручной ввод и CSV (UI), API ingest
(POST /api/onboarding/applications, per-tenant Bearer tenants.ingest_token) и публичная
форма (/onboarding/{form_token}, без создания юзера). Все → Application(source=…) → convert().
Связка кейс ↔ мониторинг (FR-46): завершение скана сайта (Scanner API sites/{id}/scanned)
у мерчанта с открытым кейсом → InvestigateCase::refreshFromScanner() (пересбор scanner-индикаторов
- пересчёт score), не затирая ручную разметку; ежегодный rerun high-risk (
investigate:rerun-dueпо расписанию); частота мониторинга сайтаautoнаследуется от риск-категории мерчанта.
Transaction Laundering (FR-50, частично): portfolio cross-check / corporate structure (PHP), whois + reverse-IP, SiteReveal, outgoing/backlinks и related-domains/редиректы (сканер → Scanner API), тест-карты (ручной ввод). Остаток — входные слова на внешних ресурсах, гео-клоакинг, load balancing.
RBAC (FR-01): роли super_admin/admin/officer/auditor (read-only) — spatie-пермишены
(Filament Shield), BelongsToTenant-scope; админ может отключать пользователей (disabled_at →
canAccessPanel=false).