Files
ksv741 329d66e767
Specs security / security (push) Successful in 8s
Specs verify / verify (push) Successful in 11s
spec: finalize wealth-ledger SDD migration
2026-10-09 21:14:22 +03:00

567 lines
60 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.
## Context
Основание — принятый [ADR 017](../../../../docs/adr/017-local-tbank-reads-and-background-sync.md)
и [дельты поведения](specs/tbank-data-sync/spec.md). Это детализация согласованной
модели для реализации. Компоненты приложения сейчас не изменены.
Исследованы локальные владельцы после чтения AGENTS каждого репозитория.
Свидетельства относятся к checkout на 2026-09-24, а не к обещаниям API:
| Граница | Текущий owner и переиспользуемое поведение |
| --- | --- |
| Хранение | `backend/prisma/schema.prisma` и `src/prisma/prisma.service.ts`: SQLite/Prisma, пользователи, сессии, подключение и ручные карты; T-Bank datasets отсутствуют. Условия владения и транзакции уже применяются в `modules/bank-cards/infrastructure/bank-cards.repository.ts`. |
| Доступ | `backend/src/integrations/tbank/tbank-access.service.ts`: `withSupportedAccounts` и `withAccountForConnection` вызывают `getOpenAccounts`; generation вычисляется из encryptedToken. `tbank-account.policy.ts` владеет допустимыми типами счетов. |
| Интеграция | `backend/src/integrations/tbank/tbank.client.ts`: общий FIFO, лимит 5 RPC/с по умолчанию, максимум 50 ожидающих, deadline и ограниченные повторы; повторный limiter не нужен. |
| Личный источник | `portfolio/application/portfolio.service.ts` хранит RAM-снимок 30 с / fallback 5 мин; `operations/application/brokerage-operations.service.ts` — страницы 30 с и обход курсоров. `income/income-analytics.service.ts` строит selected/previous/yearAgo через общий сборщик операций. Пути внутри `backend/src/modules/investments/`. |
| Общий источник | `backend/src/integrations/tbank/instrument-metadata.service.ts` уже разделяет общие metadata и pending lookup. Каталоги stocks/bonds смешивают общую информацию и личную позицию в user+generation RAM-кэше. Market services режут свечи по 365 суток. |
| Выплаты и смежные данные | `upcoming-events/upcoming-events.service.ts` отдельно получает portfolio; stock dividends приходят из T-Bank, bond events — из MOEX. Stock company use case получает asset description/fundamentals; bond metrics включают MOEX. Источники не подменяются. |
| Frontend | `frontend/src/entities/*/api` владеют generated queries; page VM собирает зависимости. `features/instrument-details/model/section.ts` знает только HTTP/query lifecycle. `features/tbank-connection/model/connection-boundary.ts` уже сбрасывает финансовую границу после успешной mutation. |
| Контракт | Backend DTO/controllers/Swagger — editable source. `frontend/src/shared/api/spec/openapi.json` — явно передаваемый snapshot. Accounts и search сейчас массивы; общего sync/coverage API нет. |
Исходный proto допускает изменение operation ID
(`backend/src/integrations/tbank/proto/contracts/operations.proto:496`),
но проект явно принимает стабильность. `is_complete` свечи задаёт источник
(`marketdata.proto:438`). В `operations.proto:472–489` не определены
inclusive/exclusive границы, срок жизни cursor и значение пропущенного `from`.
Эти пробелы не заменяются предположениями о гарантиях брокера.
## Goals / Non-Goals
**Goals:** один постоянный источник чтения в существующей БД; независимые
полнота, свежесть и состояние работы; атомарная публикация согласованного
набора; безопасный перезапуск; отсутствие RPC на пути ответа заполненной БД;
контракт всех экранных зависимостей и минимальный наблюдаемый worker.
**Non-Goals:** Redis, новый сервер БД, отдельный сервис очередей, cron,
распределённое выполнение, версии каждого портфельного снимка, live
подтверждение доступа при каждом чтении, архив отключённых счетов,
новый frontend store, изменение денежных правил или источников MOEX.
## Decisions
### 1. Существующие domain owners читают БД; RPC принадлежит синхронизации
Сохраняются controllers, application services, нормализация Money и domain
политики. Их read path получает сохранённые источники и согласованное
metadata, а запрос работы передаёт небольшому T-Bank sync coordinator.
PortfolioService становится общим владельцем последнего полного portfolio
для accounts summary, личных позиций инструментов и будущих событий.
Сборщик операций становится одним локальным источником ledger/income;
filtered HTTP page больше не является единицей источниковой полноты.
Рассчитанные доходы не требуют отдельной постоянно дублируемой таблицы.
Синхронизация использует существующие TbankClient, metadata mapping,
market-window splitting, точную денежную арифметику и MOEX adapters.
Нормализованные числа сохраняются десятичными строками, не float.
Presentation metadata не блокирует выдачу финансового снимка; fallback
UID/FIGI/name остаётся доступен до её фоновой загрузки.
Синхронная проверка token при PUT подключения сохраняется. Успешно полученный
в этой проверке список можно сразу опубликовать как membership нового
подключения в той же транзакции, без второго GetAccounts.
### 2. Личные и общие модели в SQLite
Это логические группы таблиц, не универсальный JSON-кэш всех HTTP-ответов:
| Группа | Ключ, содержимое и инвариант |
| --- | --- |
| Подключение и membership | Существующий `TbankConnection` получает opaque `revision` и состояние известного отказа token. Список открытых поддерживаемых счетов принадлежит `(userId, revision)`; хранит accountId, type, name, openedOn, upstream order, fetchedAt, membershipRevision. Список заменяется целиком атомарно, включая успешный пустой. |
| Portfolio | `(userId, revision, accountId)` → последний полный нормализованный snapshot, fetchedAt и dataRevision. Допустимо хранить состав snapshot как versioned JSON: он читается целиком, не нужен отдельный исторический агрегат. |
| Operations | Уникальность `(userId, revision, accountId, brokerOperationId)`; индекс `(userId, revision, accountId, occurredAt, brokerOperationId)`, дополнительные instrument/category поля для фильтров. Полный исходный нормализуемый payload сохраняет точные поля, executed state и version маппинга. Cursor/пустая строка не заменяют отсутствующий ID. |
| Инструменты | Общий ключ provider + instrument UID; FIGI/ticker — aliases с существующими правилами неоднозначности. Metadata, company/fundamentals и catalog discovery имеют собственные успешные timestamps и coverage; отрицательный успешный lookup отличается от ошибки. |
| Рыночные данные | Общие quote/trading-status и необходимые price dependencies; дневная свеча уникальна по `(provider, instrumentUid, interval, time)`, содержит `isComplete`. Календари имеют source/type/instrument/range и исходные события. MOEX enrichment сохраняет собственный source и существующую семантику недоступности. |
| Последний полный прогноз | Private snapshot account events и instrument personal payout estimates по `(userId, revision, account scope, instrument при наличии, applied range)`, с dependency revisions, source timestamps и calculatedAt. Заменяется только после готовности всех обязательных источников; это fallback результата, не история portfolio. |
| Покрытие и работа | Завершённые диапазоны, fetchedThrough/fetchedAt, dataRevision, известная нижняя граница и её происхождение; pending intent, attemptId, processRunId, диапазон, стадия и прогресс страниц. Staging страниц текущей попытки изолирован от опубликованного набора. |
Общие таблицы не содержат user/account/quantity/estimatedAmount или token.
Личные payout estimates, holdings и фильтрованная личная история вычисляются
из разрешённых accounts и portfolio/operations. Статус пользовательской
работы хранится отдельно от общих instrument records; ошибка credentials
не становится общим отрицательным lookup.
FK и unique constraints обеспечивают идентичность; транзакции проверяют
владение и revision. Не создаются generic repository framework, event bus
или универсальный job engine. Точные SQL-имена выбираются по локальным
Prisma conventions без изменения этой модели.
### 3. Доступ без синхронного GetAccounts
Read path: локальная сессия → существующее подключение и его revision →
известная ошибка token → membership текущей revision → данные разрешённых
счетов. Token для этого не расшифровывается.
- Membership отсутствует: `200` с `data: null`, `availability: missing` и
состоянием discovery, без данных неизвестного accountId. После discovery
следующий read возвращает данные либо прежний `404`.
- Membership свежий: разрешённые данные читаются локально.
- Membership старше пяти минут: используется последнее успешное наблюдение,
обычный запрос ставит его фоновое обновление. Это ограниченная задержка
обнаружения отзыва доступа, принятая локальной моделью, не live authority.
- Успешный новый список немедленно ограничивает все последующие одиночные
и aggregate reads; добавленный ещё не загруженный счёт делает aggregate
неполным. Ответ «Все счета» использует одну согласованную DB read transaction
с фиксированной membershipRevision.
- Известный `INVALID_TBANK_TOKEN` блокирует личные reads и возвращает прежний
`400`; `409 TBANK_NOT_CONNECTED`, `404`, session/validation errors сохраняются.
Старые записи не удаляются вследствие transient failure, но не выдаются
вопреки известному отказу доступа. Исправление token выполняется существующим PUT.
При замене успешная транзакция назначает новую случайную revision, сохраняет
новый token и очищает/делает недоступными прежние private datasets/jobs
по существующей семантике invalidation. Delete остаётся идемпотентным и
каскадно убирает личные данные подключения; специальный архив не вводится.
Общие неперсональные данные могут оставаться.
Каждая публикация данных, coverage, ошибки, timestamp и результата работы
проверяет в той же короткой транзакции текущие connection revision и
attemptId. Проверка после RPC только в памяти недостаточна. Проверка нужна
и при общей публикации, начатой credentials старого подключения.
Поздняя попытка не вправе изменить даже freshness; результаты старой revision
отбрасываются. Транзакция никогда не удерживается во время сетевого вызова.
### 4. Интервалы и атомарная сверка операций
Внутри БД диапазоны — `[from, to)` в UTC. Public semantics не меняются:
журнал принимает действующие UTC timestamps, его default начинается 1 января
UTC; даты доходов включительны в `Europe/Moscow`, и существующий
`planIncomePeriod` вычисляет selected/previous/yearAgo. Преобразование границ
выполняется один раз до планирования. Полнота доходного месяца проверяется
по объединению UTC coverage, а не по совпадению названия месяца.
Источник по операциям загружается ограниченными календарными UTC-месяцами
и фрагментами нужных диапазонов. Верхняя граница текущей попытки фиксируется
в момент её создания. Для неизвестной inclusivity upstream применяется
перекрытие соседних окон с защитной секундой; записи локально отбираются
по точному `[from, to)` и дедуплицируются по исходному ID. Это защищает
стык, но не доказывает отсутствие будущих исправлений брокера.
Обычная полнота счёта строится только по всем EXECUTED операциям интервала
без category/instrument фильтров. UI filters и pagination применяются после
чтения. Ручной refresh личной истории инструмента может перечитать только
`instrumentUid + shown range`; его reconciliation удаляет отсутствующие
записи только этого predicate и не присваивает полноту всему счёту.
Уже полное общее покрытие может обслуживать такой subset; обратное неверно.
Каждая успешная страница записывается в staging attempt с page count и
прогрессом. При наличии полного прежнего набора staging никогда не виден
как его замена. Новые большие периоды становятся доступными по завершении
малых интервалов при первичном заполнении, поэтому экран может иметь `partial` coverage без показа
непроверенных страниц. Сбой, repeated/missing next cursor, malformed ID или
противоречащие версии одного ID внутри попытки оставляют интервал незавершённым.
Идентичные дубликаты страницы безопасно схлопываются.
Только успешное исчерпание всех страниц фиксированного интервала разрешает
транзакцию публикации: upsert новых/изменённых полей, удаление отсутствующих записей
в перечитанном predicate, публикация coverage и времени успешной сверки.
Account dataRevision увеличивается только при изменении rowset;
coverage/timestamp/status без изменения строк меняют только stateVersion.
Читатель видит старый либо новый набор,
не их смесь. Между интервалами частичная готовность допустима и явно видима;
сумма всего периода окончательна только при полном покрытии его зависимостей.
Для повторной сверки уже полного показанного периода единица публикации —
весь запрошенный scope, а не отдельный календарный chunk. Staging объединяет
его интервалы, сравнения и счета (`all`) в одну publication group; успешные
страницы/окна сохраняют прогресс, но commit/upsert/delete/coverage всех
участников выполняются только после успеха группы. Любая ошибка оставляет
весь прежний результат. Такое же правило действует для ручного refresh
показанного диапазона свечей. Первичная загрузка ранее отсутствующих данных
по малым интервалам остаётся инкрементальной. Пересекающиеся работы
присоединяются к группе и не публикуют её часть независимо. DB transaction
короткая и локальная; RPC выполняются до неё, а staging не держится в RAM.
Для изменяемого конца `to=now` полнота означает «все операции по состоянию
на `asOf` последней успешной сверки». Новый ещё не наблюдённый хвост времени
в пределах того же текущего месяца и TTL не считается пропуском, запускающим
новую загрузку; ответ сохраняет исходный `asOf`, не утверждая покрытие будущих
операций. Настоящие пропуски до `asOf`, новый явно запрошенный исторический
диапазон и переход месяца этому исключению не подчиняются.
Текущий месяц обновляется целиком до зафиксированного now при истечении
пяти минут, чтобы учесть изменения уже виденных операций. После смены месяца
его покрытие до прежнего now не распространяется до конца месяца:
следующий подходящий запрос завершает хвост. Полные прошлые интервалы
не сверяются автоматически. Успешная наблюдённая смена полей того же ID
обновляет запись и производные результаты; поиск сменившихся ID отсутствует.
### 5. Планирование, приоритет и восстановление
Поддерживаемая поставка — один backend process с существующей SQLite.
Запрос сохраняет intent, возвращает локальные данные и передаёт выполнение
in-process coordinator. На startup нет обхода очереди или запуска импорта.
Process получает случайный runId; следующий подходящий запрос перехватывает
незавершённую работу другого runId новой attemptId. Persisted provider cursor
не переиспользуется после перезапуска: незавершённый интервал начинается
с первой страницы, staging прежней попытки очищается. Завершённые интервалы
и опубликованные данные сохраняются.
Приоритеты: discovery доступа → нужные экрану зависимости и интервалы,
включая comparisons → остальная история счёта и рыночная история его бумаг.
Между окнами/страницами coordinator отдаёт приоритет новой экранной работе;
уже отправленный RPC не прерывается ради перестановки. Один producer
догрузки не ставит тысячи RPC заранее: страницы идут последовательно,
новые окна планируются постепенно, через прежний bounded limiter.
Очередь RPC и bounded retry не дублируются на уровне worker.
Ключ personal work: user + connectionRevision + account + source kind +
normalized interval/predicate. Координатор сериализует публикации одного
account source и для обычной загрузки вычитает покрытые/выполняемые
пересечения; одновременно запрошенные остатки объединяет. Force refresh
не вычитает полное покрытие: он обязан перечитать запрошенный target,
но присоединяется к уже выполняемой подходящей загрузке. Повторный force refresh присоединяется к
подходящей активной попытке, уже получающей полный запрошенный диапазон;
TTL при этом не отменяет запрос. Новая область расширяет pending intent.
Shared work использует provider/instrument/source/range identity.
Общий успешный результат переиспользуется независимо от пользователя.
У каждого наблюдателя свой авторизованный handle, без выдачи чужих job IDs
и credentials errors. Ошибка token инициатора завершает его попытку, не
портит общий источник; новый допустимый запрос другого пользователя может
запустить попытку со своими credentials. Старый job не перехватывает token
другого пользователя сам.
Остальная история начинается с известной даты открытия; если её нет,
используется существующая техническая граница `1970-01-01T00:00:00Z`,
явно помеченная как requested floor, а не доказанная дата открытия/минимум
источника. Сохраняются confirmed intervals и `historyBoundary: unknown`;
«вся история загружена» не показывается до подтверждения нижней границы.
Отказ источника за пределами доступного диапазона сохраняется как ограничение,
не как пустой полный интервал. Отсутствие `from` не используется как
недокументированное обещание all-time. Это не мешает полной готовности
подтверждённых периодов экрана.
После transient failure попытка заканчивается с error; новый обычный запрос
может возобновить недостающее/устаревшее, ручной — повторить независимо от TTL.
Наблюдение результата этого не делает. Logout/уход останавливает наблюдение
frontend, но не отменяет уже запрошенную допустимую серверную работу.
Замена/delete подключения останавливает публикацию по revision fence.
### 6. Общие market data и производные выплаты
Quotes и portfolio обновляются через минуту; metadata/catalog/company и
календари — через сутки. Freshness принадлежит конкретному source/range,
поэтому успешный lookup одного инструмента не делает свежим весь каталог.
Catalog discovery сохраняет успешный набор UID/порядок отдельно от
enrichment по нормализованному поисковому query и типу инструмента, сохраняя
существующий предел результатов. `findShares/findBonds` не загружают весь
каталог: успешный поиск SBER не подтверждает результат GAZP. Новый query
без подтверждённого покрытия запускает discovery, а не возвращает ложный
пустой результат. Metadata уже известных UID переиспользуется; повтор того
же свежего полного query не вызывает discovery RPC. Отсутствие enrichment
не превращает найденный набор в пустой поиск.
Свечи сохраняют исходную `isComplete`, диапазоны и `historyFrom`.
Переиспользуются окна до 365 суток. Завершённый диапазон без свечей хранится
как проверенное пустое покрытие. Текущий UTC-день всегда изменяемый,
включая пустое окно до торгов; его TTL — минута. Смена UTC-дня сама не
закрывает ни пустое открытое окно, ни незавершённую свечу: следующий запрос
повторяет прошедшее окно; успешный закрытый пустой диапазон подтверждает
отсутствие, для имеющейся свечи нужно `isComplete=true`.
Известный `first_1dayCandleDate` определяет нижнюю границу all-range.
Если его нет, сохраняется существующее `*_HISTORY_START_UNAVAILABLE`,
не создаётся ложная полная история. Предзагрузка касается бумаг сохранённого
текущего портфеля и использует ту же общую coverage, что открытый график.
События инструментов сохраняются без пользовательской ожидаемой суммы.
Календари купонов/дивидендов и company data охватываются общим read path;
future events используют сохранённый portfolio и исходный календарь,
сохраняя неопределённость прогноза, cancelled-dividend и offer semantics.
MOEX bond events/metrics остаются MOEX: необходимые для экрана результаты
также сохраняются и обновляются в этой работе, без нового MOEX scheduler.
Недоступность optional enrichment не делает финансовые данные ошибочными;
неполнота обязательного календаря не выдаётся за полный пустой прогноз.
Последний полный прогноз/личная оценка сохраняется отдельным private
snapshot по показанному scope/range. Если новый portfolio добавил бумагу,
а её календарь не загрузился, следующий read, в том числе после restart,
возвращает прежний полный forecast с его прежними dependency timestamps и
ошибкой обновления. При отсутствии такого snapshot возвращается missing/partial
без окончательного списка. Готовый новый результат атомарно заменяет snapshot;
снимок с иным периодом или membership не выдаётся под новым scope, а доступ
всегда повторно ограничивается текущим membership.
### 7. Публичный контракт и наблюдение работы
**Contract Gate Decision: required.** Меняется wire shape всех T-Bank data
GET, включая bare-array accounts и stock/bond search. Backend сохраняет
маршруты и тип внутреннего payload, добавляя единый envelope:
```text
{ data: T | null, meta: {
resourceKey, dataRevision, stateVersion,
availability: missing | partial | complete,
freshness: unknown | fresh | stale, lastSuccessfulAt: timestamp | null,
sync: { id: opaque | null, state: idle | queued | running | failed,
errorCode: safeCode | null },
coverage?: { requested, completeRanges, missingRanges,
asOf, historyBoundary, historyState },
dependencies?: [{ resourceKey, availability, freshness,
lastSuccessfulAt, sync, coverage?, unavailableReason? }]
} }
```
Connection generation/revision остаётся внутренней: существующая security
policy запрещает её в API/logs/errors, даже если она случайная. Frontend
использует свою существующую session/connection boundary.
`resourceKey` — нормализованная идентичность запроса без user secrets и
connection generation. `dataRevision` меняется при публикации данных;
изменение статуса работы не меняет страницу операций. Availability означает
готовность обязательных источников данного результата, а не обещание полноты
рынка или будущих выплат. `data:null` используется для missing и для
неполных финансовых агрегатов; `partial` ledger/candles может содержать
подтверждённые части. Полнота selected/comparisons задаётся отдельно:
analytics `data` доступен после полного selected, а `previous` и
`yearComparison` nullable до готовности соответствующего сравнения.
`meta.dependencies` различает missing/loading/failed comparison и
заведомо недоступное по календарю/границе истории сравнение; последнее
имеет отдельный `unavailableReason` и не планируется как пропуск.
Это явное изменение вложенного analytics DTO, а не только envelope.
Для зависимостей с разными TTL metadata содержит их состояния по
resourceKey; freshness производного результата — худшее обязательное
состояние, lastSuccessfulAt — самый ранний из использованных успешных
источников. Чтение/`calculatedAt`/`observedAt` не продлевает source freshness.
Portfolio `data.lastSuccessfulAt` и `data.isStale` сохраняются для текущих
consumers и отображают тот же portfolio source, без прежнего пятиминутного
предела хранения. Error фонового обновления не меняет успешный timestamp
и сама по себе не отменяет ещё действующий TTL.
| Операция | Контракт |
| --- | --- |
| Обычный GET существующего resource | `200` envelope, в том числе cold/partial; планирует missing/stale зависимости. До постановки проверяет auth, connection и известную принадлежность. |
| Тот же GET с `sync=observe` | Только локальное чтение; никогда не создаёт и не возобновляет work, даже при missing/stale/error. Нужен для получения изменившихся данных после status observation. |
| `POST /api/v1/tbank-sync` | Валидированный screen descriptor, force по TTL; `202` с авторизованным work handle и normalized scope. При совпадении присоединяет к work. Приём не означает успех. |
| `GET /api/v1/tbank-sync/:id` | Только статус разрешённого handle, progress, changed resource revisions и terminal outcome; никаких новых запусков/повторов. Чужой/прежней connection handle — `404`. |
Descriptor — discriminated union `screen: accounts | account | operations |
income | events | stocks | bonds | stock | bond`. Поля разрешены только
соответствующему screen: account scope (конкретный ID/`all`), applied period,
filters, instrumentUid, candle period/range. Сервер использует прежние
валидаторы дат/лимитов/UID и сам раскрывает dependency graph; произвольного
списка RPC/таблиц/чужого userId во входе нет. Зафиксированный now и нормализованный
scope возвращаются клиенту, чтобы ручная операция не переезжала на другой период.
| Экран | Зависимости force refresh |
| --- | --- |
| Accounts | Membership + portfolio summary каждого текущего доступного счёта; metadata показанных позиций по необходимости. Не вся история операций. |
| Account / all overview | Membership, portfolio, показанный recent-ledger range, current-month income, upcoming events и необходимые instrument metadata/calendars. |
| Operations | Применённые account scope и период; категории — локальная проекция. Пагинация не расширяет источниковой период. |
| Income | Применённый период + previous/yearAgo из существующего planner, membership, требуемая metadata выплат. |
| Events | Membership, portfolio и прогноз применённого включительного from/to из сохранённых календарей; default from=today UTC, to=from+30 дней. |
| Stocks / bonds search | Общий каталог, результаты/enrichment текущего поиска и показываемые prices; без истории всего каталога. |
| Stock / bond | Detail/company либо metrics, quote и price dependencies, видимый candle range, payout calendar, личная position и применённый диапазон/инструмент personal operations. |
HTTP ошибки auth/input/access сохраняют `application/problem+json`.
`TBANK_UNAVAILABLE` из принятой фоновой работы передаётся как безопасный
`meta.sync.errorCode`, включая cold failure; `503` остаётся для запроса,
который не удалось принять/обслужить. Известный отказ credentials остаётся
`400 INVALID_TBANK_TOKEN`, не успешным envelope с недоступными private data.
Frontend различает server work error и transport failure.
Работа имеет terminal `succeeded | failed | cancelled` в status endpoint;
envelope отображает successful отсутствие активной работы как `idle`.
Статус имеет `stateVersion` нужных экрану ресурсов и отдельный прогресс background
history, поэтому готовые данные можно прочитать до окончания всей догрузки.
Frontend наблюдает только известный active handle на открытом экране
с интервалом 1–5 секунд и без автоматического retry failed work. После
изменения stateVersion или terminal status выполняется `sync=observe` GET.
После ухода/terminal status наблюдение прекращается. Это наблюдение уже
запрошенной работы, не периодический запуск импорта.
`stateVersion` — opaque идентичность состояния ответа, меняется при изменении
данных, coverage (включая пустое завершённое окно), successfulAt, ошибок,
работы и доступа; это не pagination revision. Он вычисляется из версий
зависимостей и состояния без выдачи внутренних connection identifiers.
Прогресс далёкой истории, не меняющий этот resource, не меняет его
stateVersion и не заставляет повторно читать уже готовый экран.
Истечение времени не создаёт работу: freshness вычисляется из timestamp.
Entity query owner записывает observe-ответ в ту же canonical cache identity
исходного GET; не создаётся второй cache по параметру observe. Query keys
берутся из generated owner, не собираются вручную. Все новые status/mutation
ответы участвуют в существующих session/connection boundary fences.
Кроме них owner использует latest-request sequence для каждого resource:
ответ более раннего запроса не заменяет уже принятый более поздний ответ.
При status notification запоминается ожидаемый stateVersion; observe-ответ
с прежней версией не публикуется, выполняется только новое observational read.
Подтверждённый access loss отменяет/инвалидирует соответствующие personal
queries и повышает локальную access epoch; даже поздний успешный ответ той же
connection, начатый до отзыва, не восстанавливает скрытые данные.
### 8. Локальная пагинация и финансовые результаты
Порядок операций сохраняет действующую сортировку с детерминированным
tie-break по accountId/operationId. Оpaque cursor связан с user, connection,
membershipRevision для all, нормализованными filters/period/limit и
account operations dataRevision. Любое изменение relevant rowset делает
cursor недействительным. Binding хранится серверно либо проверяется keyed
digest: connection generation не находится даже в декодируемом cursor payload.
Изменение rowset возвращает существующий `INVALID_OPERATIONS_CURSOR`;
исторические копии всех rowsets не хранятся. Первая реализация использует
revision на счёт: изменение другого периода тоже может сбросить его cursor;
более узкая revision нужна только при доказанно мешающих сбросах.
Frontend сбрасывает cursor stack на первую страницу, сохраняя applied filters
и объясняя обновление списка; ни страницы, ни суммы разных revisions не
склеиваются. `hasNext=false` означает конец текущей локальной выдачи,
а не полноту истории. Coverage/status без изменения строк cursor не ломают.
Income сохраняет точные signed coupon/dividend payments и существующие
comparison rules. Stable operation ID устраняет повторный финансовый учёт;
прежняя норма о разных entries с одинаковым ID явно изменена delta spec.
Analytics читает согласованный набор операций и coverage в read transaction.
Неполнота selected скрывает его окончательные totals; неполнота comparison
скрывает только его суммы/разницы и не создаёт нулевую базу. Уже опубликованный полный результат того же
period/context сохраняется при неудачной повторной сверке; данные другого
периода никогда не подписываются новым диапазоном.
### 9. Измеримый бюджет и стоимость интеграции
500 мс — цель, не текущий результат. Начальный профиль приёмки: один backend
process, локальная SQLite на SSD, 4 CPU / 8 GiB, 3 счёта, по 100 000 операций
за 10 лет, 200 инструментов и по 5 000 дневных свечей, включая пустые
интервалы; до 1 000 операций/200 доходных выплат на счёт в активном месяце.
Измеряются account/all reads, ledger page 50, monthly income с comparisons,
instrument detail/quote/company/metrics, year/all candles и payouts/search.
Бюджет — p95 ≤ 500 мс от входа HTTP на backend до окончания ответа при
10 одновременных клиентах и не менее 100 чтений каждой группы после прогрева.
Отдельно сохраняются p50/p99/max, размер ответа, состояние БД, hardware и
время первого DB-only ответа после перезапуска. Cold import исключён из
бюджета заполненной БД, но его HTTP ответ также не ожидает RPC. Больший
реальный профиль не объявляется измеренным по этим данным.
Изоляция от брокера проверяется задержанным/отказывающим T-Bank; источник
замеряемых данных — HTTP timing и счётчики RPC существующего logger. Для
fresh полной выборки ожидается 0 RPC; для 20 simultaneous stale requests —
один логический проход каждой нужной зависимости, с явным учётом pages и
разрешённых retries. После завершения, без screen/manual requests — 0 RPC.
Повторные старые полные intervals/candles, в том числе после restart, —
0 повторных RPC этих диапазонов. Сравнение с исходной реализацией выполняется
на одинаковом navigation trace; сохраняются абсолютные числа и доля снижения
`1 - new/old`, если baseline ненулевой. Измерения не требуют real tokens
в артефактах или логах.
## UI/UX Applicability and Package
**Applicability and scope:** `new/changed` — общие состояния и действие
обновления всех T-Bank экранов, без перестройки их содержимого.
**Package revision:** `1`. Пакет полностью inline; локальные PNG/SVG не
нужны, поскольку меняются информационные состояния и существующие controls,
а не композиция экранов. Self-hash и внешние визуальные зависимости отсутствуют.
**Screens, states and interactions:**
| Состояние | Представление и действие |
| --- | --- |
| Missing без результата | Существующий структурный skeleton только отсутствующих секций, «Загружаем данные», доступное busy-состояние; без нулей/«операций нет». |
| Partial period | Подтверждённые строки видны с «Период загружен не полностью». Финансовые totals/comparisons ждут полноты. «История догружается» отдельно от состояния показанного периода. |
| Complete fresh | Контент, время источника и «Обновить» в заголовке экрана. Истечение TTL само не запускает UI timer refresh. |
| Complete stale / active | Прежний контент и timestamp, «Обновляем» либо «Данные на …»; без skeleton поверх сумм. Повторная кнопка disabled для уже запрошенного текущего scope с доступным пояснением. |
| Failed with data | Прежний контент, исходное время, безопасное предупреждение и повторное «Обновить». Transport failure и server work failure различимы; статус не сообщает об успешном обновлении. |
| Failed without data | Локальное error-состояние секции, безопасный retry либо действие настроек согласно коду. |
| Complete empty | Только подтверждённое пустое состояние; empty portfolio не создаёт нулевые карточки, пустой доход объясняет отсутствие купонов/дивидендов. |
| Access lost / connection changed | Очистка недоступного personal view, настройки для token/connection, возврат к счетам для 404. Нельзя удерживать чужую/прежнюю revision под предупреждением. |
Accounts показывают freshness списка отдельно от локального состояния
portfolio summary каждой карточки. Account/all overview сохраняет независимые
секции. Operations сохраняет применённые фильтры; income не переименовывает
старый snapshot новым period. Stock/bond показывают состояния по sections,
не смешивая общий график с личными holdings/payout estimates. Детали candle
по-прежнему обозначают незавершённость.
**Responsive behavior:** на 320–375 px timestamp, предупреждение и кнопка
переносятся без горизонтального overflow и обрезания сумм; на широком экране
используют существующий header/section layout. Focus сохраняется после
обновления; button доступен с клавиатуры, текст статуса не зависит от цвета,
`aria-live=polite` объявляет смену стадии/ошибку, но не каждую страницу.
Skeleton не фокусируется; reduced motion сохраняется.
**Reused source and differences:** runtime Mantine controls, существующие
Section/VM patterns и generated query owners переиспользуются как код.
Это новый inline пакет, не reuse изменяемого или архивного design package;
его состояние полностью определено выше.
## Delivery Map
| Area | Status | Architectural outcome or N/A reason |
| --- | --- | --- |
| frontend | `affected` | Новый snapshot/types, metadata-aware entity queries/VMs всех экранов, scoped refresh и observe-only status, connection/cursor boundaries, inline UI package. |
| backend | `affected` | Prisma storage/migration, local access/read owners, durable interval sync/fencing, shared market sources, DTO и OpenAPI; evidence корректности, latency и RPC. |
| specs | `affected` | Этот change и семь delta capabilities; traceability реализации без преждевременного изменения main specs или чужих changes. |
| docs | `affected` | As-built freshness/storage/access/flows/API/glossary и актуальный статус ADR после поставки; не второй источник требований. |
## Solution Assessment
Trigger: storage, external integration, caching, concurrency и public API.
| Вариант | Fit, maturity, security, operating cost и migration cost |
| --- | --- |
| Существующие SQLite/Prisma + транзакции, unique keys и небольшой request-driven coordinator | Выбран: зрелая используемая БД, один процесс, отсутствие нового сервиса; ownership/fencing доступны в SQL. Миграция добавляет таблицы и revision. Специфичные broker intervals/reconciliation остаются доменной логикой. |
| Продлить RAM/TanStack кэш | Зрелый дешёвый механизм показа, но несовместим с durability и полнотой после restart. Остаётся presentation cache, не решение хранения. |
| Redis + BullMQ | Зрелые retries/locks/queues, но требует нового сервиса, persistence/security/backup и двойной согласованности с SQLite; его jobs всё равно не решают domain coverage/атомарную сверку. Нет нужды в распределённых workers, поэтому цена миграции/эксплуатации не оправдана. |
| Temporal либо аналогичный workflow server | Зрелое recovery, но отдельная инфраструктура и execution model для одной последовательности RPC; domain data transactions всё равно необходимы. Не соответствует минимальному одному backend. |
| Cron/планировщик | Зрелый механизм, но несовместим с явным запретом периодических запусков. |
Собственный код ограничен доменными интервалами, dependency planning,
staging/publish и coalescing; transport queue/retries и DB lifecycle уже
существуют. Существующий runtime не предоставляет durable job framework;
добавлять его ради этих конечных стадий не требуется. При переходе к нескольким
backend processes понадобится отдельное решение о durable claims/leases;
эта поставка не выдаёт processRunId за распределённую блокировку.
## Risks / Trade-offs
- Локальный membership не замечает отзыв доступа мгновенно. Он обновляется
по пятиминутной политике; известный отказ применяется немедленно.
- Stable operation ID противоречит возможностям upstream proto, но принят
как допущение; сменившийся ID может потребовать отдельной будущей сверки.
- Broker cursor не обещает snapshot isolation: полнота означает успешный
обход доступного интервала на момент сверки, не отсутствие последующих
исправлений. Staging защищает от наших частичных публикаций.
- Долгая первичная история расходует rate-limit и диск. Ограниченные окна,
приоритет экрана и отсутствие предварительной массовой очереди сдерживают
ресурсы; объём и время первой загрузки пока не измерены.
- Запись SQLite сериализуется. Короткие транзакции и индексы должны уложиться
в бюджет; это проверяется, а не считается доказанным архитектурой.
- Envelope — несовместимое wire изменение, поэтому backend/frontend
поставляются согласованной парой. API version path сам по себе не делает
старый frontend совместимым.
## Migration Plan
1. Зафиксировать текущие DB/API invariants и measurement baseline на
изолированной базе. Добавить additive schema с revision и пустыми новыми
datasets; текущие users/sessions/connection/manual-card записи сохранить.
У существующих подключений появляется новая revision без изменения token.
2. Реализовать storage/coordinator и local owners; старые RAM snapshots
не мигрировать как доказанно полную историю. Проверить crash/rollback и
fences до включения нового read path.
3. Backend публикует OpenAPI artifact; frontend получает его явно и
регенерирует типы локально. Совместно поставляется новый UI и server
read path, включая observer semantics и terminal errors.
4. Первые пользовательские запросы заполняют данные. Startup/deploy не
запускает импорт. После подтверждённых checks обновляются as-built docs;
specs остаётся единственным SDD authority.
### Rollback Contract
| Field | Decision |
| --- | --- |
| Status | `required` |
| Trigger | Cross-user/connection leak, частичная публикация финансовых данных, повреждение миграции, несовместимый API pair либо существенная регрессия latency. |
| Actions | Остановить новый worker/backend, сохранить согласованную копию SQLite, вернуть совместимую пару backend/frontend. Не запускать старую и новую write-модель одновременно. Отдельно проверить Prisma migration state; не удалять additive tables как способ отката. |
| Data safety | Сохранить новые datasets, progress и все прежние User/Session/TbankConnection/manual-card записи. Если старая версия не поддерживает новую Prisma schema, использовать совместимый rollback build либо восстановить backup в отдельной БД; нельзя перезаписать рабочую БД старой копией, потеряв новые ручные операции. |
| Verification | Проверить integrity/FK и равенство прежних пользовательских данных, невозможность публикации остановленного worker, auth/connection boundaries, согласованность API pair и повторное безопасное чтение/возобновление после возврата исправленной версии. |
## Open Questions
Блокирующих продуктовых вопросов нет. Неизвестны измеренные latency/объём
истории и provider semantics при отсутствующем `from`/openedDate. Они не
меняют выбранный путь: явные интервалы, replay с начала, unknown lower bound
и отсутствие ложного признака полной истории. Подтверждение минимальной
даты источника может уточнить только boundary metadata; до него используется
описанный безопасный fallback. Числовые результаты будут получены задачами
проверки, а не вписаны заранее.