Metadata-Version: 2.5
Name: s-accountpoolkit
Version: 0.5.25
Summary: Зрелый пул аккаунтов/сессий поверх librarykit: статус-машина аккаунта, quota/подписки, rate-limit + circuit-breaker, OAuth-ротация (single-flight DCL-lock), egress-пул с quarantine, import/export, headless-фасад. Обобщение gemini-balancer в переиспользуемый доменный слой.
Author: Dmitry
License: MIT
License-File: LICENSE
Requires-Python: >=3.11
Requires-Dist: platformdirs>=4.0
Requires-Dist: pydantic>=2.6
Requires-Dist: s-corekit>=0.0.11
Requires-Dist: s-librarykit>=0.7.20
Requires-Dist: s-netkit>=0.0.15
Requires-Dist: s-ormkit>=0.0.1
Provides-Extra: adapters
Requires-Dist: s-adapterkit>=0.1.4; extra == 'adapters'
Provides-Extra: cli
Requires-Dist: s-clikit>=0.1; extra == 'cli'
Provides-Extra: client
Requires-Dist: httpx>=0.27; extra == 'client'
Provides-Extra: egress
Requires-Dist: s-librarykit[antibot]>=0.7.20; extra == 'egress'
Provides-Extra: rest
Requires-Dist: starlette>=0.37; extra == 'rest'
Requires-Dist: uvicorn>=0.30; extra == 'rest'
Description-Content-Type: text/markdown

# s-accountpoolkit

Зрелый **пул аккаунтов/сессий** поверх [`s-librarykit`](https://gitlab.com/S-kits/librarykit) — доменный слой над `SessionStore`/`SessionPool`/`RateLimiter`/`RotatingAuth`/antibot-транспортами. Обобщение gemini-balancer в переиспользуемый кит.

**import-имя** `accountpoolkit` · **dist-имя** `s-accountpoolkit` (как s-librarykit → librarykit).

## Что даёт (сверх librarykit)

- **Account со статус-машиной** — `available / cooldown / quota_exhausted / disabled / blocked` с причинами и таймстемпами (не строка+`is_active`).
- **Quota / подписки** — `QuotaData` union (dual-window weekly+5h / header-based / JWT-claims), приоритет `ULTRA > PRO > FREE`, model-level изоляция.
- **Таксономия подписок по семействам** (`accountpoolkit.subscription`) — провайдеры одного вендора делят подписку, но тиры разные при общей оси free/paid: google (Plus/Pro/Ultra 5x·20x), openai (Go/Plus/Edu/Pro 5x·20x), anthropic (Pro/Max 5x·20x), telegram (Premium). Каждый тир несёт грубый `SubscriptionTier` для селектора; у `Account` — `plan`/`subscription_expires_at`/`project` (срок подписки + ярлык-проект).
- **Rate-limit + circuit-breaker** — разбор 429/403/5xx/Retry-After, exp-backoff по причинам, cooldown отдельно от CB-open, авто-recovery.
- **Selector** — двухслойный (жёсткий eligibility-фильтр → Power-of-Two-Choices), приоритет-каскад подписка→квота→health→reset, sticky-сессии, slow-start.
- **OAuth-ротация** — single-flight (double-checked-locking) на аккаунт, атомарный rolling-refresh, fallback-цепочка OAuth-клиентов, `invalid_grant` → карантин, проактивный фоновый рефреш, keyring-экспорт токена.
- **Egress-пул** — привязка аккаунт↔прокси (sticky IP) vs глобальный пул, стратегии (RR/Weighted/LeastConn/Priority/P2C), health-check, **настоящий per-proxy circuit-breaker/quarantine**.
- **Общий вход у поставщика личности (SSO)** — вход в Google хранится ОДИН раз и переиспользуется всеми сервисами и пулами; отдельное состояние «сессия сервиса жива, вход отозван»; продление одно на всех (см. ниже).
- **Журнал событий идентичности** — append-only история аккаунта (выдача, вход, продление, ротация, операция, отказ по лимиту, челлендж, бан, перелогин, смена выхода) с выходом/устройством/уликой и выборкой «что предшествовало бану». Без секретов by-design; отказ журнала не роняет работу (см. ниже).
- **Риск аккаунта одним числом** — показатель из журнала (свежее весит больше, бан ≠ отказ по лимиту) со слагаемыми, из которых он сложился, и третьим состоянием «истории не хватает». Селектор уточняется им осознанно — флагом, с режимом наблюдения (см. ниже).
- **Темп бизнес-операций** — аккаунт не совершает операций чаще человеческого темпа, заданного для РОДА операции (отправка, публикация, поиск, экспорт, вход): границы в час и в сутки, промежуток между двумя подряд, пауза с разбросом. Считается по журналу, работает проактивно (не совершить раньше срока, а не отреагировать на 429), включается осознанно — флагом, с режимом наблюдения (см. ниже).
- **Устройство аккаунта (персона)** — одно устройство на аккаунт: заводится при первой выдаче, лежит в реестре, переживает перезапуск и версионируется (`corekit.persona`). Выдача отдаёт персону ВМЕСТЕ с адресом выхода и сводит их между собой; смена устройства — событие журнала (см. ниже).
- **Выход по требованию** — тип выхода (`datacenter | residential | mobile` + честное «неизвестно»), ASN и страна в модели прокси/инбаунда; дорогой выход выдаётся только когда его просят (см. ниже).
- **Import/export** — версионированный конверт, идемпотентный upsert, чтение чужих сторов.
- **Headless `AccountService`-фасад** + `clikit` CLI (json-by-default); опц. REST admin (`[rest]`, `accountpoolkit.rest.create_admin_app`) + cloudflared quick-tunnel (`accountpoolkit.tunnel`, нужен бинарь `cloudflared` на PATH — не pip-пакет).

Секреты — только через librarykit `SessionStore` (envelope KEK/DEK) / `SecretStore` (keyring+fallback). Никакого plaintext.

Провайдер-специфика (gemini / codex / …) — через `AccountProvider` Protocol-плагины; ядро её не знает.

## Установка

```bash
uv add s-accountpoolkit          # ядро
uv add "s-accountpoolkit[egress]"  # + antibot health-транспорт для egress-пула
uv add "s-accountpoolkit[cli]"     # + CLI `accountpool` (json-by-default)
```

import-имя — `accountpoolkit`. Корень пакета ЛЕНИВЫЙ (PEP 562): `import
accountpoolkit` не поднимает ни sqlalchemy, ни librarykit (101 мс против 2019 мс
у жадного корня) — тяжёлое приезжает при первом обращении к имени. На способ
работы это не влияет: `apk.AccountStore`, `from accountpoolkit import *` и
`accountpoolkit.repository` дают ровно те же объекты, что и раньше.

`AccountService` — headless-фасад над всеми слоями:

```python
import accountpoolkit as apk
from librarykit.sessions import SessionStore

store = apk.AccountStore(SessionStore(root=None, encrypt=True), social="myservice")
pool = apk.AccountPool(store, tracker=apk.RateLimitTracker())
svc = apk.AccountService(store=store, pool=pool)

choice = await svc.acquire(require_tier=apk.SubscriptionTier.PRO)
if choice:
    ...  # запрос через choice.proxy (egress) + choice.account (device);
    # секреты хранятся отдельно (envelope): await store.load_creds(choice.ref)
    await svc.report(choice.ref, response=resp)  # 429/5xx → cooldown/circuit-breaker
```

## Общий вход у поставщика личности (SSO)

Аккаунт, заведённый «через Google», держится на ДВУХ входах сразу: сессии самого
сервиса и входе у Google. Сроки жизни у них разные, и умирают они порознь —
ChatGPT продолжает отвечать, когда войти в Google уже нельзя. Пока состояний
было два, этот случай прятался в «всё хорошо» и всплывал в час, когда вход
понадобился.

**Вход хранится один раз.** `SsoLogin` — запись по ключу «поставщик + кого он
узнаёт» (`google` + почта). Тело входа лежит в общем хранилище сессий по
каноническому адресу `sso_session_ref(...)` — профиль `sso`, а не профиль
потребителя, иначе у каждого профиля завелась бы своя копия. Аккаунты на него
ССЫЛАЮТСЯ: `Identity.sso_login_id` в реестре и пара «провайдер + почта» в строке
состояния сессии. Копии на навык нет ни одной.

**Три состояния вместо двух** (`SessionHealthSnapshot.auth_state`):

| состояние | что произошло | что чинить |
|---|---|---|
| `healthy` | сессия сервиса жива, плохих улик о входе нет | ничего |
| `sso_expired` | сессия сервиса ЖИВА, вход у поставщика МЁРТВ | восстановить вход; сервис не трогать, работа идёт |
| `unauthenticated` | мертва сама сессия сервиса | перелогин в сервис |
| `unknown` | сервис недоступен / лимит / капча | переспросить позже |

`logged_out` ставится ТОЛЬКО по прямой улике: поля формы входа (`identifierId`,
`Passwd`) или ответ 401/403 от поставщика. Ссылка «войти» на странице и сетевой
сбой уликами не считаются — по догадке выключаются рабочие аккаунты. Улика
хранится рядом с вердиктом и уходит в отчёт.

**Продление одно на всех.** Человек входит ОДИН раз; поколение входа растёт, и
каждый навык узнаёт об этом сам — его сессия отстала от поколения, а новое тело
уже лежит в общем хранилище:

```python
from accountpoolkit.domain import SsoEvidence
from accountpoolkit.services import SsoService

sso = SsoService(session_factory, store=db_store)
sso.attach("google", "me@gmail.com", service="gemini", account="me@gmail.com", profile=uid)
sso.attach("google", "me@gmail.com", service="chatgpt", account="me@gmail.com", profile=uid)

# наблюдение по улике — одно на ВСЕ сервисы, вход-то один
sso.observe("google", "me@gmail.com", SsoEvidence(status_code=401))   # → logged_out

# вход перехвачен заново: тело уезжает в общее хранилище, поколение +1
await sso.publish("google", "me@gmail.com", storage_state)

# навык спрашивает про СВОЮ сессию и подхватывает общий вход — человека не зовут
if sso.needs_resync("google", "me@gmail.com", service="gemini",
                    account="me@gmail.com", profile=uid):
    body = await sso.body("google", "me@gmail.com")     # один источник на всех
    ...                                                  # пере-минт сессии сервиса
    sso.mark_synced("google", "me@gmail.com", service="gemini",
                    account="me@gmail.com", profile=uid)
```

Аккаунт с мёртвым входом НЕ выключается из выдачи: работа идёт, и отсечь его
значило бы сломать её. Чинить надо вход, а не сервис.

`await sso.adopt(...)` дополнительно помечает сессию в ИНДЕКСЕ хранилища
(`credential_group` — задел librarykit «один логин ⇒ несколько сетей»), чтобы
ответ на «кто ляжет вместе с этим входом» был один, а не два расходящихся.

## Журнал событий идентичности (что предшествовало бану)

У аккаунта есть ИСТОРИЯ, а не только счётчики и мгновенное состояние. Журнал
append-only живёт в том же хранилище, что реестр, и отвечает на вопрос, ради
которого заведён: **что происходило перед баном**.

```python
from accountpoolkit import IdentityJournal

journal = IdentityJournal.open()          # или Gate(...).journal

# ЦКП: события до последнего бана И сам бан последней строкой
for e in journal.before_ban(identity_id=17, limit=20):
    print(e.at, e.kind, e.outcome, e.code, e.egress_host or "—", e.egress_country)

# лента за окно (по аккаунту реестра или по имени аккаунта)
journal.feed(identity_id=17, since=..., limit=100)
journal.feed(account="me@gmail.com", service="gemini-chat")
```

Из CLI (json-by-default):

```bash
accountpool journal before-ban --identity-id 17 --limit 20
accountpool journal feed --account me@gmail.com --hours 24 --kind limit
```

Событие отвечает на «кто, когда, чем и с каким исходом»: момент (UTC), аккаунт и
сервис, сессия и проект-потребитель, ВЫХОД (хост/страна/ASN ноды), устройство
(ссылка — заполняется разметкой устройств), род (`EventKind`: выдача,
освобождение, вход, продление, ротация токена, операция, отказ по лимиту,
челлендж, бан, перелогин, смена выхода), исход (`EventOutcome`) и улику —
МАШИННЫЙ КОД из уже существующих словарей (`LimitReason`, `HealthState`,
`SsoState`, `AccessState`).

**Секретов в журнале нет и быть не может.** Не по дисциплине пишущего, а по
устройству: свободного поля под «тело ответа» или «подробности» в контракте не
существует, а улика и приметы проходят фильтр формы и длины — токен, кука и
заголовок авторизации туда не пролезают. Журнал append-only: попавший в него
секрет остался бы там навсегда.

**Журнал не важнее работы.** Его отказ никогда не роняет горячий путь: выдача
аккаунта происходит и тогда, когда записать событие не удалось.

**Ретенция — 90 дней** (`ACCOUNTPOOL_JOURNAL_RETENTION_DAYS`, ноль — «не
убирать»). Столько живёт окно расследования: бан выясняется не сразу, а всплеск
надо сравнивать с несколькими нормальными циклами недельных лимитов; дальше
квартала — уже архив, а не расследование.

## Риск аккаунта одним числом

Журнал даёт историю, а показатель риска сводит её к ОДНОМУ сравнимому числу:
**насколько рискованно идти этим аккаунтом прямо сейчас**. Раньше на этот
вопрос отвечать было нечем — признаки описывали момент (жив ли, сколько
осталось, сколько подряд упало), а рискованность это свойство истории.

```python
from accountpoolkit import IdentityJournal
from accountpoolkit.services import RiskScorer

risk = RiskScorer(IdentityJournal.open()).assess(identity_id=17, account="me@gmail.com")
print(risk.score, risk.level)   # 63.4 high
print(risk.explain())           # me@gmail.com: риск 63.4 (high) по 41 событиям за 30 дн
                                # — ban 60.0 (1), challenge 2.4 (1), limit 1.0 (12)
```

```bash
accountpool journal risk --account me@gmail.com
```

Как считается: **свежее весит больше** (полураспад 14 дней — бан вчера и бан
три месяца назад это разные вещи), **роды весят по-разному** (бан 60, челлендж
15, отказ по лимиту 1 — лимит про исчерпание, а не про риск), сумма обрезается
сотней. Рядом с числом всегда едут **слагаемые** (`components`): род, сколько
событий, вклад в баллах, когда случилось последнее и какие коды-улики
встретились — иначе показателем нельзя пользоваться при расследовании.

**«Нет данных» — не «низкий риск».** У свежего аккаунта уровень `unknown`, а не
`low`: он непроверен, а не безупречен. Плохие улики при этом перебивают нехватку
данных — единственное событие-бан даёт `high`, а не «мало данных».

**Связь с селектором — осознанная.** По умолчанию учёт риска ВЫКЛЮЧЕН и не
делает ни одного лишнего запроса. Включается флагом
`ACCOUNTPOOL_RISK_AWARE_SELECTOR`: `shadow` — считать и логировать расхождения,
не трогая боевой выдачи; `on` — уточнять каскад (полоса риска встаёт между
health и reset). Отсечения по риску нет: рискованный аккаунт идёт позже, но
остаётся кандидатом.

## Темп бизнес-операций (не чаще, чем это делает человек)

Технический темп кит держал давно — RPM/RPD, окна квот, circuit-breaker. Всё
это про КАНАЛ: «сто запросов в минуту» не говорит ничего о том, нормально ли
для человека опубликовать сорок постов подряд. А признаки современная защита
строит вокруг БИЗНЕС-ОПЕРАЦИЙ: подделать один заголовок дешевле, чем весь
жизненный цикл аккаунта, — поэтому проверяют жизненный цикл.

```python
from accountpoolkit import IdentityJournal, OperationPacer

pacer = OperationPacer(IdentityJournal.open())

план = pacer.plan("publish", identity_id=17, account="me@vk.com")
print(план.explain())
# me@vk.com: publish — ждать 1123.4 сек (hour_budget); за час 10/10,
# за сутки 23/40; режим on

pacer.wait("publish", identity_id=17)     # выждать (в корутине — asyncio.sleep)
...                                       # сделать операцию
pacer.note("publish", identity_id=17)     # отметить в журнале
```

**Род операции — данные, а не набор `if`.** Кит знает из коробки пять родов —
`send` (45/час, 300/сутки), `publish` (10/40), `search` (120/800), `export`
(6/30), `login` (3/10) — с минимальным промежутком между двумя подряд.
Границы перебиваются конструктором или переменной окружения
`ACCOUNTPOOL_OPERATION_TEMPO` (`publish=6/25/90, my_export=2/8/300`), а
незнакомый род получает осторожное умолчание: навык, заведший свою операцию,
узнаёт об этом не остановкой работы.

**Отклонения нет — есть отсрочка.** План говорит «подожди столько-то», а не
«нельзя»: отказ вернулся бы вызывающему ошибкой, ошибка ушла бы в ретрай, а
ретрай — это ещё более плотный темп.

**Пауза с разбросом.** Ровно N операций в час через равные промежутки — сам по
себе машинный признак; пауза гуляет в пределах `+35%` (разброс только вверх —
пауза короче границы перестала бы быть границей).

**Считается по журналу, который уже пишется.** Род операции едет машинным кодом
в `IdentityEvent.code` события `operation`, выборка дешёвая (только моменты, по
индексу, окно — сутки). Второго счётчика в памяти нет: он не пережил бы
перезапуск, а история переживает.

**Включение осознанное.** По умолчанию темп ВЫКЛЮЧЕН и не делает ни одного
запроса. `ACCOUNTPOOL_OPERATION_PACE=shadow` — считать и показывать, что было
бы отложено (`plan.advised_seconds`, `pacer.last_shadow`), ничего не
откладывая; `on` — откладывать. Поломка подсчёта даёт честное «можно сразу»
(`pace_unavailable`) — как и у журнала, темп не важнее работы.

## Выход по требованию (тип и ASN)

У выхода есть **тип** (`datacenter | residential | mobile` плюс честное
`unknown`), **ASN** и страна: именно их защита видит на краю, ещё до первого
запроса. Выход выдаётся ПО ТРЕБОВАНИЮ:

```python
mgr.choose()                        # требования нет → дешёвый (датацентр)
mgr.choose(require="residential")   # нужен домашний адрес → резидентский/мобильный
mgr.bind(identity_id, "nl-1", require="residential")   # мимо требования → ValueError
```

Резидентские прокси стоят денег, поэтому умолчание их не расходует: дорогой
выход выдаётся по явному требованию, а не «на всякий случай». Неразмеченная
нода (`unknown`) требование дороже датацентра НЕ закрывает — отказ честнее
подмены. Разметка задаётся при заведении инбаунда (`add(..., egress_kind=…,
asn=…)`) и переживает рестарт.

## Устройство аккаунта (персона)

Выдача отдаёт не только адрес выхода, но и **устройство**, которым этот аккаунт
представляется:

```python
granted = await gate.acquire("проект", "gemini-chat")
granted.proxy_endpoint      # socks5h://127.0.0.1:10801 — чем ходим
granted.persona.persona_id  # dev-d648b243a3e8 — КЕМ приходим (стабильно)
granted.persona.version     # 3 — поколение заявления
granted.persona.timezone    # Europe/Helsinki — часы из страны выхода
```

**Одно устройство на аккаунт, а не на вызов.** Раньше выдача отдавала только
адрес, устройство до потребителя не доезжало вовсе, и один аккаунт
представлялся разными клиентами при одном и том же IP. Для скоринга устройства
это худший из рисунков: стабильная сеть при плавающем клиенте читается как угон
сессии. Персона лежит в строке `device` (имя и поколение — колонки, слои — JSON)
и одна на все сервисы аккаунта: у человека один ноутбук на все сайты.

**Обновление — это `bump`, а не подмена.** Обновился Chrome, сменилась
платформа, переехал выход — растёт `version`, `persona_id` остаётся. Смена имени
означала бы для защиты нового посетителя: потерянное узнавание устройства и
подтверждение входа на ровном месте.

**Персона сводится с выходом, и молча это не делается.** Часы принадлежат
машине, а машина стоит там, откуда приходит запрос: пояс, спорящий со страной
выхода, — ошибка уровня «ходить нельзя». Такое расхождение ЧИНИТСЯ (выход
переставили мы) — с поднятием поколения и записью `device_switch` в журнал. А
расхождение, которое сменой места не лечится (окно больше экрана, десктопная
платформа при мобильном признаке, рукопожатие, отставшее от заголовков), —
`PersonaConflict`: чинить его значило бы подменить само устройство.

Чем мы выглядим (версия браузера, язык) кит НЕ пинит — это знает netkit, одно
место на систему; наблюдение можно задать явно:

```python
from accountpoolkit.services import Gate, PersonaService, PersonaTraits

gate = Gate(persona=PersonaService(traits=PersonaTraits(browser_version="152.0.8100.10")))
```

## Схема хранилища: сверка, применение, ОДИН дом

Схема живого файла обязана отвечать на вопрос «совпадаю ли я с кодом» ДО первого
чтения данных. Пока такого ответа не было, расхождение приезжало наружу
`OperationalError: no such column: identity.sso_login_id` — из глубины запроса,
где его ловил чужой `except` и выдавал наверх **«пул пуст»** при двух аккаунтах и
восьми сессиях в файле. Ответ был неверен дважды: и про пустоту, и про
исправность.

```bash
accountpool schema status          # три исхода: matches / stale / unreadable
accountpool schema apply           # сухой прогон: показать дельту, НЕ трогать
accountpool schema apply --yes     # копия рядом → применить → сверить ЗАНОВО
accountpool schema homes           # один дом или два, и как их свести
```

```python
from accountpoolkit.repository import check_schema, get_engine

delta = check_schema(get_engine())
print(delta.state)         # SchemaState.STALE
print(delta.summary())     # «схема … СТАРШЕ кода — нет колонок: identity.sso_login_id …»
print(delta.rows)          # {'identity': 2, 'session': 8, …} — база НЕ пуста
```

**Три исхода не схлопываются в два.** `matches` — читать можно; `stale` —
названо поимённо, каких таблиц и колонок не хватает и устарел ли уникальный ключ
адреса; `unreadable` — до схемы не добрались вовсе (нет прав, битый файл), а это
лечится другим, и выдавать одно за другое значит отправить человека чинить не то.
Отдельно назван первый запуск (`database_is_new`): пустое хранилище — это не
старая форма поверх живых данных. Вместе с дельтой едут **счётчики строк**, чтобы
«схема отстала» и «в базе пусто» больше никогда не путались.

**Правка подтверждается и показывает разницу.** `schema apply` без `--yes` не
трогает ничего. С `--yes` порядок такой: снять дельту → положить копию файла
рядом (`registry.db.pre-schema-<время>.bak`) → применить → **сверить заново**.
«Применилось» доказывается второй сверкой, а не тем, что команда не упала.

**Читающая дверь ловит расхождение сама.** `Registry(url=…)` и `open_registry`
без `init` сверяют схему на открытии и поднимают `SchemaOutdated` с дельтой
вместо невнятного отказа на первом же SELECT.

**Про дом — он ОДИН.** Схема у реестра аккаунтов и у тела сессий одна
(`Base.metadata`), а путей по умолчанию было два: нейтральный platform-appdir и
канонный каталог сессий корневого кита. Границы предметной области здесь нет: не
бывает «реестра аккаунтов» отдельно от «сессий этих аккаунтов» — балансировать
нечем, если аккаунт в одном файле, а его вход в другом. Это были **два умолчания
об одном хранилище**, а не два хранилища, и разошлись они молча: у владельца в
одном файле лежали 2 аккаунта, 8 сессий, 12 сервисов и 42 модели при пустом
`session_state`, а 12 живых входов — в другом.

С 0.5.25 умолчание одно: `~/.sessions/sessions.db` (`SESSIONS_DB_URL` /
`SESSIONS_HOME`), туда же по умолчанию ведёт и реестр. Явные адреса
(`ACCOUNTPOOL_DB_URL` / `ACCOUNTPOOL_HOME`) по-прежнему перебивают всё. **Старый
platform-appdir не брошен**: пока в его файле есть таблицы, читается он, и кит
говорит об этом вслух (лог + раздел `legacy` в `schema homes`) — тихая смена пути
под живым потребителем выглядит как потеря данных из ниоткуда. Пустой файл, заново
созданный по старому адресу потребителем прежней версии, домом не считается:
иначе сведение отменялось бы само.

```bash
accountpool storage merge          # сухой прогон: построчная дельта, НИЧЕГО не тронуто
accountpool storage merge --yes    # копия ОБОИХ файлов → перелив → сверка → источник в сторону
```

Сведение — слияние, а не копия файла поверх: в цели уже лежат живые входы и свой
`consumer_account`. Строки сопоставляются по **природному ключу** (уникальные
ограничения), а не по `id` — номера в двух файлах выдавались независимо; ссылки
детей переписываются на новые номера родителей. Совпавшая строка не копируется:
побеждает целевая (в ней живое состояние — приоритеты пула, аренда), а
расхождение по колонкам **перечисляется**, а не глотается. Строка, чей родитель
не нашёлся в источнике вовсе (в боевом файле такая была — способ входа для
удалённого сервиса), помечается `broken`: перенос отказывается, пока человек не
скажет `--skip-broken`, и тогда она остаётся в старом файле. Источник не
удаляется — он уводится в `registry.db.merged-<время>.bak`.

## Статус

Ядро стабильно: статус-машина аккаунта, quota/подписки, rate-limit + circuit-breaker,
OAuth-ротация (single-flight), egress-пул, import/export, `AccountService`-фасад + CLI.
Провайдер-плагины подключаются через entry-points `accountpoolkit.providers`.
